Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

16
contacts/app/build.gradle Normal file
View file

@ -0,0 +1,16 @@
plugins {
id 'signal-sample-app'
alias(libs.plugins.compose.compiler)
}
android {
namespace 'org.signal.contactstest'
defaultConfig {
applicationId "org.signal.contactstest"
}
}
dependencies {
implementation project(':contacts')
}

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ContactsTest">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ContactListActivity"
android:exported="false" />
<activity
android:name=".ContactLookupActivity"
android:exported="false" />
<service
android:name=".AccountAuthenticatorService"
android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".ContactsSyncAdapterService"
android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter" />
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contactsformat" />
</service>
</application>
</manifest>

View file

@ -0,0 +1,63 @@
package org.signal.contactstest
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
class AccountAuthenticatorService : Service() {
companion object {
private var accountAuthenticator: AccountAuthenticatorImpl? = null
}
override fun onBind(intent: Intent): IBinder? {
return if (intent.action == AccountManager.ACTION_AUTHENTICATOR_INTENT) {
getOrCreateAuthenticator().iBinder
} else {
null
}
}
@Synchronized
private fun getOrCreateAuthenticator(): AccountAuthenticatorImpl {
if (accountAuthenticator == null) {
accountAuthenticator = AccountAuthenticatorImpl(this)
}
return accountAuthenticator as AccountAuthenticatorImpl
}
private class AccountAuthenticatorImpl(context: Context) : AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse, accountType: String, authTokenType: String, requiredFeatures: Array<String>, options: Bundle): Bundle? {
return null
}
override fun confirmCredentials(response: AccountAuthenticatorResponse, account: Account, options: Bundle): Bundle? {
return null
}
override fun editProperties(response: AccountAuthenticatorResponse, accountType: String): Bundle? {
return null
}
override fun getAuthToken(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? {
return null
}
override fun getAuthTokenLabel(authTokenType: String): String? {
return null
}
override fun hasFeatures(response: AccountAuthenticatorResponse, account: Account, features: Array<String>): Bundle? {
return null
}
override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? {
return null
}
}
}

View file

@ -0,0 +1,31 @@
package org.signal.contactstest
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class ContactListActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_contact_list)
val list: RecyclerView = findViewById(R.id.list)
val adapter = ContactsAdapter { uri ->
startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = uri
}
)
}
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
val viewModel: ContactListViewModel by viewModels()
viewModel.contacts.observe(this) { adapter.submitList(it) }
}
}

View file

@ -0,0 +1,56 @@
package org.signal.contactstest
import android.accounts.Account
import android.app.Application
import android.telephony.PhoneNumberUtils
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.signal.contacts.SystemContactsRepository
import org.signal.contacts.SystemContactsRepository.ContactDetails
import org.signal.contacts.SystemContactsRepository.ContactIterator
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
class ContactListViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(ContactListViewModel::class.java)
}
private val _contacts: MutableLiveData<List<ContactDetails>> = MutableLiveData()
val contacts: LiveData<List<ContactDetails>>
get() = _contacts
init {
SignalExecutors.BOUNDED.execute {
val account: Account? = SystemContactsRepository.getOrCreateSystemAccount(
context = application,
applicationId = BuildConfig.APPLICATION_ID,
accountDisplayName = "Test"
)
val startTime: Long = System.currentTimeMillis()
if (account != null) {
val contactList: List<ContactDetails> = SystemContactsRepository.getAllSystemContacts(
context = application,
e164Formatter = { number -> PhoneNumberUtils.formatNumberToE164(number, "US") ?: number }
).use { it.toList().sortedBy { c -> c.givenName } }
_contacts.postValue(contactList)
} else {
Log.w(TAG, "Failed to create an account!")
}
Log.d(TAG, "Took ${System.currentTimeMillis() - startTime} ms to fetch contacts.")
}
}
private fun ContactIterator.toList(): List<ContactDetails> {
val list: MutableList<ContactDetails> = mutableListOf()
forEach { list += it }
return list
}
}

View file

@ -0,0 +1,40 @@
package org.signal.contactstest
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class ContactLookupActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_contact_lookup)
val list: RecyclerView = findViewById(R.id.list)
val adapter = ContactsAdapter { uri ->
startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = uri
}
)
}
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
val viewModel: ContactLookupViewModel by viewModels()
viewModel.contacts.observe(this) { adapter.submitList(it) }
val lookupText: TextView = findViewById(R.id.lookup_text)
val lookupButton: Button = findViewById(R.id.lookup_button)
lookupButton.setOnClickListener {
viewModel.onLookup(lookupText.text.toString())
}
}
}

View file

@ -0,0 +1,57 @@
package org.signal.contactstest
import android.accounts.Account
import android.app.Application
import android.telephony.PhoneNumberUtils
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.signal.contacts.SystemContactsRepository
import org.signal.contacts.SystemContactsRepository.ContactDetails
import org.signal.contacts.SystemContactsRepository.ContactIterator
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
class ContactLookupViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(ContactLookupViewModel::class.java)
}
private val _contacts: MutableLiveData<List<ContactDetails>> = MutableLiveData()
val contacts: LiveData<List<ContactDetails>>
get() = _contacts
fun onLookup(lookup: String) {
SignalExecutors.BOUNDED.execute {
val account: Account? = SystemContactsRepository.getOrCreateSystemAccount(
context = getApplication(),
applicationId = BuildConfig.APPLICATION_ID,
accountDisplayName = "Test"
)
val startTime: Long = System.currentTimeMillis()
if (account != null) {
val contactList: List<ContactDetails> = SystemContactsRepository.getContactDetailsByQueries(
context = getApplication(),
queries = listOf(lookup),
e164Formatter = { number -> PhoneNumberUtils.formatNumberToE164(number, "US") ?: number }
).use { it.toList() }
_contacts.postValue(contactList)
} else {
Log.w(TAG, "Failed to create an account!")
}
Log.d(TAG, "Took ${System.currentTimeMillis() - startTime} ms to fetch contacts.")
}
}
private fun ContactIterator.toList(): List<ContactDetails> {
val list: MutableList<ContactDetails> = mutableListOf()
forEach { list += it }
return list
}
}

View file

@ -0,0 +1,50 @@
package org.signal.contactstest
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.signal.contacts.SystemContactsRepository
class ContactsAdapter(private val onContactClickedListener: (Uri) -> Unit) : ListAdapter<SystemContactsRepository.ContactDetails, ContactsAdapter.ContactViewHolder>(object : DiffUtil.ItemCallback<SystemContactsRepository.ContactDetails>() {
override fun areItemsTheSame(oldItem: SystemContactsRepository.ContactDetails, newItem: SystemContactsRepository.ContactDetails): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: SystemContactsRepository.ContactDetails, newItem: SystemContactsRepository.ContactDetails): Boolean {
return oldItem == newItem
}
}) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder {
return ContactViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.parent_item, parent, false))
}
override fun onBindViewHolder(holder: ContactViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class ContactViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val givenName: TextView = itemView.findViewById(R.id.given_name)
private val familyName: TextView = itemView.findViewById(R.id.family_name)
private val phoneAdapter: PhoneAdapter = PhoneAdapter(onContactClickedListener)
init {
itemView.findViewById<RecyclerView?>(R.id.phone_list).apply {
layoutManager = LinearLayoutManager(itemView.context)
adapter = phoneAdapter
}
}
fun bind(contact: SystemContactsRepository.ContactDetails) {
givenName.text = "Given Name: ${contact.givenName}"
familyName.text = "Family Name: ${contact.familyName}"
phoneAdapter.submitList(contact.numbers)
}
}
}

View file

@ -0,0 +1,33 @@
package org.signal.contactstest
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import org.signal.core.util.logging.Log
class ContactsSyncAdapter(context: Context?, autoInitialize: Boolean) : AbstractThreadedSyncAdapter(context, autoInitialize) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Log.i(TAG, "onPerformSync()")
}
override fun onSyncCanceled() {
Log.w(TAG, "onSyncCanceled()")
}
override fun onSyncCanceled(thread: Thread) {
Log.w(TAG, "onSyncCanceled($thread)")
}
companion object {
private val TAG = Log.tag(ContactsSyncAdapter::class.java)
}
}

View file

@ -0,0 +1,22 @@
package org.signal.contactstest
import android.app.Service
import android.content.Intent
import android.os.IBinder
class ContactsSyncAdapterService : Service() {
@Synchronized
override fun onCreate() {
if (syncAdapter == null) {
syncAdapter = ContactsSyncAdapter(this, true)
}
}
override fun onBind(intent: Intent): IBinder? {
return syncAdapter!!.syncAdapterBinder
}
companion object {
private var syncAdapter: ContactsSyncAdapter? = null
}
}

View file

@ -0,0 +1,131 @@
package org.signal.contactstest
import android.Manifest
import android.accounts.Account
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.telephony.PhoneNumberUtils
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.signal.contacts.ContactLinkConfiguration
import org.signal.contacts.SystemContactsRepository
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
class MainActivity : AppCompatActivity() {
companion object {
private val TAG = Log.tag(MainActivity::class.java)
private const val PERMISSION_CODE = 7
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<Button>(R.id.contact_list_button).setOnClickListener { v ->
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
startActivity(Intent(this, ContactListActivity::class.java))
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), PERMISSION_CODE)
}
}
findViewById<Button>(R.id.contact_lookup_button).setOnClickListener { v ->
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
startActivity(Intent(this, ContactLookupActivity::class.java))
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), PERMISSION_CODE)
}
}
findViewById<Button>(R.id.link_contacts_button).setOnClickListener { v ->
val startTime = System.currentTimeMillis()
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
SimpleTask.run({
val allE164s: Set<String> = SystemContactsRepository.getAllDisplayNumbers(this).map { PhoneNumberUtils.formatNumberToE164(it, "US") }.toSet()
val account: Account = SystemContactsRepository.getOrCreateSystemAccount(this, BuildConfig.APPLICATION_ID, "Contact Test") ?: return@run false
SystemContactsRepository.addMessageAndCallLinksToContacts(
context = this,
config = buildLinkConfig(account),
targetE164s = allE164s,
removeIfMissing = true
)
return@run true
}, { success ->
if (success) {
Toast.makeText(this, "Success! Took ${System.currentTimeMillis() - startTime} ms", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
}
})
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), PERMISSION_CODE)
}
}
findViewById<Button>(R.id.unlink_contact_button).setOnClickListener { v ->
val startTime = System.currentTimeMillis()
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
SimpleTask.run({
val account: Account = SystemContactsRepository.getOrCreateSystemAccount(this, BuildConfig.APPLICATION_ID, "Contact Test") ?: return@run false
SystemContactsRepository.addMessageAndCallLinksToContacts(
context = this,
config = buildLinkConfig(account),
targetE164s = emptySet(),
removeIfMissing = true
)
return@run true
}, { success ->
if (success) {
Toast.makeText(this, "Success! Took ${System.currentTimeMillis() - startTime} ms", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
}
})
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), PERMISSION_CODE)
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == PERMISSION_CODE) {
if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startActivity(Intent(this, ContactListActivity::class.java))
} else {
Toast.makeText(this, "You must provide permissions to continue.", Toast.LENGTH_SHORT).show()
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
private fun buildLinkConfig(account: Account): ContactLinkConfiguration {
return ContactLinkConfiguration(
account = account,
appName = "Contact Test",
messagePrompt = { "(Test) Message $it" },
callPrompt = { "(Test) Call $it" },
e164Formatter = { PhoneNumberUtils.formatNumberToE164(it, "US") },
messageMimetype = "vnd.android.cursor.item/vnd.org.signal.contacts.test.message",
callMimetype = "vnd.android.cursor.item/vnd.org.signal.contacts.test.call",
syncTag = "__TEST",
videoCallMimetype = "",
videoCallPrompt = { "" }
)
}
}

View file

@ -0,0 +1,53 @@
package org.signal.contactstest
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.ContactsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.signal.contacts.SystemContactsRepository
class PhoneAdapter(private val onContactClickedListener: (Uri) -> Unit) : ListAdapter<SystemContactsRepository.ContactPhoneDetails, PhoneAdapter.PhoneViewHolder>(object : DiffUtil.ItemCallback<SystemContactsRepository.ContactPhoneDetails>() {
override fun areItemsTheSame(oldItem: SystemContactsRepository.ContactPhoneDetails, newItem: SystemContactsRepository.ContactPhoneDetails): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: SystemContactsRepository.ContactPhoneDetails, newItem: SystemContactsRepository.ContactPhoneDetails): Boolean {
return oldItem == newItem
}
}) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhoneViewHolder {
return PhoneViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.child_item, parent, false))
}
override fun onBindViewHolder(holder: PhoneViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class PhoneViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val photo: ImageView = itemView.findViewById(R.id.contact_photo)
private val displayName: TextView = itemView.findViewById(R.id.display_name)
private val number: TextView = itemView.findViewById(R.id.number)
private val type: TextView = itemView.findViewById(R.id.type)
private val goButton: View = itemView.findViewById(R.id.go_button)
fun bind(details: SystemContactsRepository.ContactPhoneDetails) {
if (details.photoUri != null) {
photo.setImageBitmap(BitmapFactory.decodeStream(itemView.context.contentResolver.openInputStream(Uri.parse(details.photoUri))))
} else {
photo.setImageBitmap(null)
}
displayName.text = details.displayName
number.text = details.number
type.text = ContactsContract.CommonDataKinds.Phone.getTypeLabel(itemView.resources, details.type, details.label)
goButton.setOnClickListener { onContactClickedListener(details.contactUri) }
}
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#FFFFFF">
<group android:scaleX="2.0097"
android:scaleY="2.0097"
android:translateX="29.8836"
android:translateY="29.8836">
<path
android:fillColor="@android:color/white"
android:pathData="M20,0L4,0v2h16L20,0zM4,24h16v-2L4,22v2zM20,4L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM12,6.75c1.24,0 2.25,1.01 2.25,2.25s-1.01,2.25 -2.25,2.25S9.75,10.24 9.75,9 10.76,6.75 12,6.75zM17,17L7,17v-1.5c0,-1.67 3.33,-2.5 5,-2.5s5,0.83 5,2.5L17,17z"/>
</group>
</vector>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:clipChildren="false" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/lookup_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/lookup_button"
app:layout_constraintTop_toTopOf="parent"/>
<Button
android:id="@+id/lookup_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lookup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:clipChildren="false"
app:layout_constraintTop_toBottomOf="@id/lookup_text"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/contact_list_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Contact List"
app:layout_constraintBottom_toTopOf="@id/contact_lookup_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"/>
<Button
android:id="@+id/contact_lookup_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Contact Lookup"
app:layout_constraintBottom_toTopOf="@id/link_contacts_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contact_list_button"
app:layout_constraintVertical_chainStyle="packed"/>
<Button
android:id="@+id/link_contacts_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Link Contacts"
app:layout_constraintBottom_toTopOf="@id/unlink_contact_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contact_lookup_button" />
<Button
android:id="@+id/unlink_contact_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unlink Contacts"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/link_contacts_button" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:clipChildren="false"
android:clipToPadding="false"
app:cardCornerRadius="5dp"
app:cardElevation="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp">
<ImageView
android:id="@+id/contact_photo"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginBottom="2dp"
tools:text="Spider-Man"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/contact_photo"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/number"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="(111) 222-3333"
app:layout_constraintTop_toBottomOf="@id/display_name"
app:layout_constraintStart_toStartOf="@id/display_name"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/type"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Mobile"
app:layout_constraintTop_toBottomOf="@id/number"
app:layout_constraintStart_toStartOf="@id/number"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/go_button"
android:layout_width="60dp"
android:layout_height="0dp"
android:text="Go"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp"
android:clipToPadding="false"
android:clipChildren="false">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="5dp"
app:cardCornerRadius="5dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="4dp"
android:clipChildren="false"
android:clipToPadding="false">
<TextView
android:id="@+id/given_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Spider-Man"/>
<TextView
android:id="@+id/family_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
tools:text="Spider-Man"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/phone_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"/>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ContactsTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#3A76F0</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">ContactsTest</string>
</resources>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ContactsTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#2c6bed</item>
<item name="colorPrimaryVariant">#1851b4</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,6 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="org.signal.contactstest"
android:icon="@mipmap/ic_launcher"
android:smallIcon="@mipmap/ic_launcher"
android:label="@string/app_name"/>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<ContactsSource
xmlns:android="http://schemas.android.com/apk/res/android">
<ContactsDataKind
android:detailColumn="data3"
android:detailSocialSummary="true"
android:icon="@mipmap/ic_launcher"
android:mimeType="vnd.android.cursor.item/vnd.org.signal.contacts.test.message"
android:summaryColumn="data2" />
<ContactsDataKind
android:detailColumn="data3"
android:icon="@mipmap/ic_launcher"
android:mimeType="vnd.android.cursor.item/vnd.org.signal.contacts.test.call"
android:summaryColumn="data2" />
</ContactsSource>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter
xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="org.signal.contactstest"
android:allowParallelSyncs="false"
android:contentAuthority="com.android.contacts"
android:isAlwaysSyncable="true"
android:supportsUploading="true"
android:userVisible="true" />

11
contacts/lib/build.gradle Normal file
View file

@ -0,0 +1,11 @@
plugins {
id 'signal-library'
}
android {
namespace 'org.signal.contacts'
}
dependencies {
implementation project(':core-util')
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
</manifest>

View file

@ -0,0 +1,26 @@
package org.signal.contacts
import android.accounts.Account
/**
* Describes how you'd like message and call links added to the system contacts.
*
* [appName] The name of the app
* [messagePrompt] A function that, given a formatted number, will output a string to be used as a label for the message link on a contact
* [callPrompt] A function that, given a formatted number, will output a string to be used as a label for the call link on a contact
* [e164Formatter] A function that, given a formatted number, will output an E164 of that number
* [messageMimetype] The mimetype you'd like to use for the message link
* [callMimetype] The mimetype you'd like to use for the call link
*/
class ContactLinkConfiguration(
val account: Account,
val appName: String,
val messagePrompt: (String) -> String,
val callPrompt: (String) -> String,
val videoCallPrompt: (String) -> String,
val e164Formatter: (String) -> String?,
val messageMimetype: String,
val callMimetype: String,
val videoCallMimetype: String,
val syncTag: String
)

View file

@ -0,0 +1,876 @@
package org.signal.contacts
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.Context
import android.content.OperationApplicationException
import android.database.Cursor
import android.net.Uri
import android.os.RemoteException
import android.provider.BaseColumns
import android.provider.ContactsContract
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import java.io.Closeable
import java.util.Objects
/**
* A way to retrieve and update data in the Android system contacts.
*
* Contacts in Android are miserable, but they're reasonably well-documented here:
* https://developer.android.com/guide/topics/providers/contacts-provider
*
* But here's a summary of how contacts are stored.
*
* There's three main entities:
* - Contacts
* - RawContacts
* - ContactData
*
* Each Contact can have multiple RawContacts associated with it, and each RawContact can have multiple ContactDatas associated with it.
*
* Contact
*
*
* RawContact RawContact RawContact
*
* Data Data Data
*
* Data Data Data
*
* Data Data Data
*
* (Shortened ContactData -> Data for space)
*
* How are they linked together?
* - Each RawContact has a [ContactsContract.RawContacts.CONTACT_ID] that links to a [ContactsContract.Contacts._ID]
* - Each ContactData has a [ContactsContract.Data.RAW_CONTACT_ID] column that links to a [ContactsContract.RawContacts._ID]
* - Each ContactData has a [ContactsContract.Data.CONTACT_ID] column that links to a [ContactsContract.Contacts._ID]
* - Each ContactData has a [ContactsContract.Data.LOOKUP_KEY] column that links to a [ContactsContract.Contacts.LOOKUP_KEY]
* - The lookup key is a way to link back to a Contact in a more stable way. Apparently linking using the CONTACT_ID can lead to unstable results if a sync
* is happening or data is otherwise corrupted.
*
* What type of stuff are stored in each?
* - Contact only really has metadata about the contact. Basically the stuff you see at the top of the contact entry in the contacts app, like:
* - Photo
* - Display name (*not* structured name)
* - Whether or not it's starred
* - RawContact also only really has metadata, largely about which account it's bound to
* - ContactData is where all the actual contact details are, stuff like:
* - Phone
* - Email
* - Structured name
* - Address
* - ContactData has a [ContactsContract.Data.MIMETYPE] that will tell you what kind of data is it. Common ones are [ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE]
* and [ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE]
* - You can imagine that it's tricky to come up with a schema that can store arbitrary contact data -- that's why a lot of the columns in ContactData are just
* generic things, like [ContactsContract.Data.DATA1]. Thankfully aliases have been provided for common types, like [ContactsContract.CommonDataKinds.Phone.NUMBER],
* which is an alias for [ContactsContract.Data.DATA1].
*
*
*/
object SystemContactsRepository {
private val TAG = Log.tag(SystemContactsRepository::class.java)
private const val FIELD_DISPLAY_PHONE = ContactsContract.RawContacts.SYNC1
private const val FIELD_TAG = ContactsContract.Data.SYNC2
private const val FIELD_SUPPORTS_VOICE = ContactsContract.RawContacts.SYNC4
/**
* Gets and returns an iterator over data for all contacts, containing both phone number data and structured name data.
*
* In order to get all of this in one query, we have to query all of the ContactData items with the appropriate mimetypes, and then group it together by
* lookup key.
*/
@JvmStatic
fun getAllSystemContacts(context: Context, e164Formatter: (String) -> String?): ContactIterator {
val uri = ContactsContract.Data.CONTENT_URI
val projection = arrayOf(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.LABEL,
ContactsContract.CommonDataKinds.Phone.PHOTO_URI,
ContactsContract.CommonDataKinds.Phone._ID,
ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
)
val where = "${ContactsContract.Data.MIMETYPE} IN (?, ?)"
val args = SqlUtil.buildArgs(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
val orderBy = "${ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY} ASC, ${ContactsContract.Data.MIMETYPE} DESC, ${ContactsContract.CommonDataKinds.Phone._ID} DESC"
val cursor: Cursor = context.contentResolver.query(uri, projection, where, args, orderBy) ?: return EmptyContactIterator()
return CursorContactIterator(cursor, e164Formatter)
}
@JvmStatic
fun getContactDetailsByQueries(context: Context, queries: List<String>, e164Formatter: (String) -> String?): ContactIterator {
val lookupKeys: MutableSet<String> = mutableSetOf()
for (query in queries) {
val lookupKeyUri: Uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI, Uri.encode(query))
context.contentResolver.query(lookupKeyUri, arrayOf(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY), null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val lookup: String? = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)
if (lookup != null) {
lookupKeys += lookup
}
}
}
}
if (lookupKeys.isEmpty()) {
return EmptyContactIterator()
}
val uri = ContactsContract.Data.CONTENT_URI
val projection = arrayOf(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.LABEL,
ContactsContract.CommonDataKinds.Phone.PHOTO_URI,
ContactsContract.CommonDataKinds.Phone._ID,
ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
)
val lookupPlaceholder = lookupKeys.map { "?" }.joinToString(separator = ",")
val where = "${ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY} IN ($lookupPlaceholder) AND ${ContactsContract.Data.MIMETYPE} IN (?, ?)"
val args = lookupKeys.toTypedArray() + SqlUtil.buildArgs(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
val orderBy = "${ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY} ASC, ${ContactsContract.Data.MIMETYPE} DESC, ${ContactsContract.CommonDataKinds.Phone._ID} DESC"
val cursor: Cursor = context.contentResolver.query(uri, projection, where, args, orderBy) ?: return EmptyContactIterator()
return CursorContactIterator(cursor, e164Formatter)
}
/**
* Retrieves all unique display numbers in the system contacts. (By display, we mean not-E164-formatted)
*/
@JvmStatic
fun getAllDisplayNumbers(context: Context): Set<String> {
val results: MutableSet<String> = mutableSetOf()
context.contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val formattedPhone: String? = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER)
if (formattedPhone != null && formattedPhone.isNotEmpty()) {
results.add(formattedPhone)
}
}
}
return results
}
/**
* Retrieves a system account for the provided applicationId, creating one if necessary.
*/
@JvmStatic
fun getOrCreateSystemAccount(context: Context, applicationId: String, accountDisplayName: String): Account? {
val accountManager: AccountManager = AccountManager.get(context)
val accounts: Array<Account> = accountManager.getAccountsByType(applicationId)
var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null
if (account == null) {
try {
Log.i(TAG, "Attempting to create a new account...")
val newAccount = Account(accountDisplayName, applicationId)
if (accountManager.addAccountExplicitly(newAccount, null, null)) {
Log.i(TAG, "Successfully created a new account.")
ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1)
account = newAccount
} else {
Log.w(TAG, "Failed to create a new account!")
}
} catch (e: SecurityException) {
Log.w(TAG, "Failed to add an account.", e)
}
}
if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
Log.i(TAG, "Updated account to sync automatically.")
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
return account
}
/**
* Deletes all raw contacts the specified account that are flagged as deleted.
*/
@JvmStatic
@Synchronized
fun removeDeletedRawContactsForAccount(context: Context, account: Account) {
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
val projection = arrayOf(BaseColumns._ID, FIELD_DISPLAY_PHONE)
// TODO Could we write this as a single delete(DELETED = true)?
context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
while (cursor.moveToNext()) {
val rawContactId = cursor.requireLong(BaseColumns._ID)
Log.i(TAG, "Deleting raw contact: ${cursor.requireString(FIELD_DISPLAY_PHONE)}, $rawContactId")
context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", SqlUtil.buildArgs(rawContactId))
}
}
}
/**
* Adds links to message and call using your app to the system contacts.
* [config] Your configuration object.
* [targetE164s] A list of E164s whose contact entries you would like to add links to.
* [removeIfMissing] If true, links will be removed from all contacts not in the [targetE164s].
*/
@JvmStatic
@Synchronized
@Throws(RemoteException::class, OperationApplicationException::class)
fun addMessageAndCallLinksToContacts(
context: Context,
config: ContactLinkConfiguration,
targetE164s: Set<String>,
removeIfMissing: Boolean
) {
val operations: ArrayList<ContentProviderOperation> = ArrayList()
val currentLinkedContacts: Map<String, LinkedContactDetails> = getLinkedContactsByE164(context, config.account, config.e164Formatter)
val targetChunks: List<List<String>> = targetE164s.chunked(50).toList()
for (targetChunk in targetChunks) {
for (target in targetChunk) {
if (!currentLinkedContacts.containsKey(target)) {
val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, target, config.e164Formatter)
if (systemContactInfo != null) {
Log.i(TAG, "Adding number: $target")
operations += buildAddRawContactOperations(
operationIndex = operations.size,
linkConfig = config,
systemContactInfo = systemContactInfo
)
}
}
}
if (operations.isNotEmpty()) {
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
operations.clear()
}
}
for ((e164, details) in currentLinkedContacts) {
if (!targetE164s.contains(e164)) {
if (removeIfMissing) {
Log.i(TAG, "Removing number: $e164")
removeLinkedContact(operations, config.account, details.id)
}
} else if (!Objects.equals(details.rawDisplayName, details.aggregateDisplayName)) {
Log.i(TAG, "Updating display name: $e164")
operations += buildUpdateDisplayNameOperations(details.aggregateDisplayName, details.id, details.displayNameSource)
}
}
if (operations.isNotEmpty()) {
operations
.chunked(50)
.forEach { batch ->
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ArrayList(batch))
}
}
}
@JvmStatic
fun getNameDetails(context: Context, contactId: Long): NameDetails? {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
NameDetails(
displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
)
} else {
null
}
}
}
@JvmStatic
fun getOrganizationName(context: Context, contactId: Long): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
@JvmStatic
fun getPhoneDetails(context: Context, contactId: Long): List<PhoneDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
val phoneDetails: MutableList<PhoneDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
phoneDetails += PhoneDetails(
number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
)
}
}
return phoneDetails
}
@JvmStatic
fun getEmailDetails(context: Context, contactId: Long): List<EmailDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.CommonDataKinds.Email.TYPE,
ContactsContract.CommonDataKinds.Email.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
val emailDetails: MutableList<EmailDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
emailDetails += EmailDetails(
address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
)
}
}
return emailDetails
}
@JvmStatic
fun getPostalAddressDetails(context: Context, contactId: Long): List<PostalAddressDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
ContactsContract.CommonDataKinds.StructuredPostal.STREET,
ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
ContactsContract.CommonDataKinds.StructuredPostal.CITY,
ContactsContract.CommonDataKinds.StructuredPostal.REGION,
ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
val postalDetails: MutableList<PostalAddressDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
postalDetails += PostalAddressDetails(
type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
)
}
}
return postalDetails
}
@JvmStatic
fun getAvatarUri(context: Context, contactId: Long): Uri? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val uri = cursor.getString(0)
if (uri != null) {
return Uri.parse(uri)
}
}
}
return null
}
private fun buildUpdateDisplayNameOperations(
displayName: String?,
rawContactId: Long,
displayNameSource: Int
): ContentProviderOperation {
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
return if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
ContentProviderOperation.newInsert(dataUri)
.withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build()
} else {
ContentProviderOperation.newUpdate(dataUri)
.withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE))
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build()
}
}
private fun buildAddRawContactOperations(
operationIndex: Int,
linkConfig: ContactLinkConfiguration,
systemContactInfo: SystemContactInfo
): List<ContentProviderOperation> {
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
return listOf(
// RawContact entry
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, linkConfig.account.name)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, linkConfig.account.type)
.withValue(FIELD_DISPLAY_PHONE, systemContactInfo.displayPhone)
.withValue(FIELD_SUPPORTS_VOICE, true.toString())
.build(),
// Data entry for name
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, systemContactInfo.name.displayName)
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, systemContactInfo.name.givenName)
.withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, systemContactInfo.name.familyName)
.withValue(ContactsContract.CommonDataKinds.StructuredName.PREFIX, systemContactInfo.name.prefix)
.withValue(ContactsContract.CommonDataKinds.StructuredName.SUFFIX, systemContactInfo.name.suffix)
.withValue(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME, systemContactInfo.name.middleName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build(),
// Data entry for number (Note: This may not be necessary)
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, systemContactInfo.displayPhone)
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, systemContactInfo.type)
.withValue(FIELD_TAG, linkConfig.syncTag)
.build(),
// Data entry for sending a message
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.messageMimetype)
.withValue(ContactsContract.Data.DATA1, systemContactInfo.displayPhone)
.withValue(ContactsContract.Data.DATA2, linkConfig.appName)
.withValue(ContactsContract.Data.DATA3, linkConfig.messagePrompt(systemContactInfo.displayPhone))
.withYieldAllowed(true)
.build(),
// Data entry for making a call
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.callMimetype)
.withValue(ContactsContract.Data.DATA1, systemContactInfo.displayPhone)
.withValue(ContactsContract.Data.DATA2, linkConfig.appName)
.withValue(ContactsContract.Data.DATA3, linkConfig.callPrompt(systemContactInfo.displayPhone))
.withYieldAllowed(true)
.build(),
// Data entry for making a video call
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.videoCallMimetype)
.withValue(ContactsContract.Data.DATA1, systemContactInfo.displayPhone)
.withValue(ContactsContract.Data.DATA2, linkConfig.appName)
.withValue(ContactsContract.Data.DATA3, linkConfig.videoCallPrompt(systemContactInfo.displayPhone))
.withYieldAllowed(true)
.build(),
// Ensures that this RawContact entry is shown next to another RawContact entry we found for this contact
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, systemContactInfo.siblingRawContactId)
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, operationIndex)
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
.build()
)
}
private fun removeLinkedContact(operations: MutableList<ContentProviderOperation>, account: Account, rowId: Long) {
operations.add(
ContentProviderOperation.newDelete(
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
)
.withYieldAllowed(true)
.withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
.build()
)
}
private fun getLinkedContactsByE164(context: Context, account: Account, e164Formatter: (String) -> String?): Map<String, LinkedContactDetails> {
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
val projection = arrayOf(
BaseColumns._ID,
FIELD_DISPLAY_PHONE,
FIELD_SUPPORTS_VOICE,
ContactsContract.RawContacts.CONTACT_ID,
ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY,
ContactsContract.RawContacts.DISPLAY_NAME_SOURCE
)
val contactsDetails: MutableMap<String, LinkedContactDetails> = HashMap()
context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val displayPhone = cursor.requireString(FIELD_DISPLAY_PHONE)
if (displayPhone != null) {
val e164 = e164Formatter(displayPhone) ?: continue
contactsDetails[e164] = LinkedContactDetails(
id = cursor.requireLong(BaseColumns._ID),
supportsVoice = cursor.requireString(FIELD_SUPPORTS_VOICE),
rawDisplayName = cursor.requireString(ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY),
aggregateDisplayName = getDisplayName(context, cursor.requireLong(ContactsContract.RawContacts.CONTACT_ID)),
displayNameSource = cursor.requireInt(ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
)
}
}
}
return contactsDetails
}
private fun getSystemContactInfo(context: Context, e164: String, e164Formatter: (String) -> String?): SystemContactInfo? {
ContactsContract.RawContactsEntity.RAW_CONTACT_ID
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(e164))
val projection = arrayOf(
ContactsContract.PhoneLookup.NUMBER,
ContactsContract.PhoneLookup._ID,
ContactsContract.PhoneLookup.DISPLAY_NAME,
ContactsContract.PhoneLookup.TYPE
)
context.contentResolver.query(uri, projection, null, null, null)?.use { contactCursor ->
while (contactCursor.moveToNext()) {
val systemNumber: String? = contactCursor.requireString(ContactsContract.PhoneLookup.NUMBER)
if (systemNumber != null && e164Formatter(systemNumber) == e164) {
val phoneLookupId = contactCursor.requireLong(ContactsContract.PhoneLookup._ID)
val idProjection = arrayOf(ContactsContract.RawContacts._ID)
val idSelection = "${ContactsContract.RawContacts.CONTACT_ID} = ? "
val idArgs = SqlUtil.buildArgs(phoneLookupId)
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, idProjection, idSelection, idArgs, null)?.use { idCursor ->
if (idCursor.moveToNext()) {
val rawContactId = idCursor.requireLong(ContactsContract.RawContacts._ID)
val nameProjection = arrayOf(
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
)
val nameSelection = "${ContactsContract.Data.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val nameArgs = SqlUtil.buildArgs(rawContactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, nameProjection, nameSelection, nameArgs, null)?.use { nameCursor ->
if (nameCursor.moveToNext()) {
return SystemContactInfo(
name = NameDetails(
displayName = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
givenName = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
prefix = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
suffix = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
middleName = nameCursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
),
displayPhone = systemNumber,
siblingRawContactId = rawContactId,
type = contactCursor.requireInt(ContactsContract.PhoneLookup.TYPE)
)
}
}
}
}
}
}
}
return null
}
private fun getDisplayName(context: Context, contactId: Long): String? {
val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
val selection = "${ContactsContract.Contacts._ID} = ?"
val args = SqlUtil.buildArgs(contactId)
context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
interface ContactIterator : Iterator<ContactDetails>, Closeable {
@Throws
override fun close() {
}
}
private class EmptyContactIterator : ContactIterator {
override fun close() {}
override fun hasNext(): Boolean = false
override fun next(): ContactDetails = throw NoSuchElementException()
}
/**
* Remember cursor rows are ordered by the following params:
* 1. Contact Lookup Key ASC
* 1. Mimetype ASC
* 1. id DESC
*
* The lookup key is a fixed value that allows you to verify two rows in the database actually
* belong to the same contact, since the contact uri can be unstable (if a sync fails, say.)
*
* We order by id explicitly here for the same contact sync failure error, which could result in
* multiple structured name rows for the same user. By ordering by id DESC, we ensure that the
* latest name is first in the cursor.
*
* What this results in is a cursor that looks like:
*
* Alice phone 2
* Alice phone 1
* Alice structured name 2
* Alice structured name 1
* Bob phone 1
* ... etc.
*
* The general idea of how this is implemented:
* - Assume you're already on the correct row at the start of [next].
* - Store the lookup key from the first row.
* - Read all phone entries for that lookup key and store them.
* - Read the first name entry for that lookup key and store it.
* - Skip all other rows for that lookup key. This will ensure that you're on the correct row for the next call to [next]
*/
private class CursorContactIterator(
private val cursor: Cursor,
private val e164Formatter: (String) -> String?
) : ContactIterator {
init {
cursor.moveToFirst()
}
override fun hasNext(): Boolean {
return !cursor.isAfterLast && cursor.position >= 0
}
override fun next(): ContactDetails {
if (cursor.isAfterLast || cursor.position < 0) {
throw NoSuchElementException()
}
val lookupKey: String = cursor.getLookupKey()
val phoneDetails: List<ContactPhoneDetails> = readAllPhones(cursor, lookupKey)
val structuredName: StructuredName? = readStructuredName(cursor, lookupKey)
while (!cursor.isAfterLast && cursor.position >= 0 && cursor.getLookupKey() == lookupKey) {
cursor.moveToNext()
}
return ContactDetails(
givenName = structuredName?.givenName,
familyName = structuredName?.familyName,
numbers = phoneDetails
)
}
override fun close() {
cursor.close()
}
fun readAllPhones(cursor: Cursor, lookupKey: String): List<ContactPhoneDetails> {
val phoneDetails: MutableList<ContactPhoneDetails> = mutableListOf()
while (!cursor.isAfterLast && lookupKey == cursor.getLookupKey() && cursor.isPhoneMimeType()) {
val displayNumber: String? = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER)
val formattedNumber: String? = displayNumber?.let { e164Formatter(it) }
if (!displayNumber.isNullOrEmpty() && !formattedNumber.isNullOrEmpty()) {
phoneDetails += ContactPhoneDetails(
contactUri = ContactsContract.Contacts.getLookupUri(cursor.requireLong(ContactsContract.CommonDataKinds.Phone._ID), lookupKey),
displayName = cursor.requireString(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME),
photoUri = cursor.requireString(ContactsContract.CommonDataKinds.Phone.PHOTO_URI),
number = formattedNumber,
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
)
} else {
Log.w(TAG, "Skipping phone entry with invalid number!")
}
cursor.moveToNext()
}
// You may get duplicates of the same phone number with different types.
// This dedupes by taking the entry with the lowest phone type.
return phoneDetails
.groupBy { it.number }
.mapValues { entry ->
entry.value.minByOrNull { it.type }!!
}
.values
.toList()
}
fun readStructuredName(cursor: Cursor, lookupKey: String): StructuredName? {
return if (!cursor.isAfterLast && cursor.getLookupKey() == lookupKey && cursor.isNameMimeType()) {
StructuredName(
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
)
} else {
null
}
}
fun Cursor.getLookupKey(): String {
return requireNonNullString(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)
}
fun Cursor.isPhoneMimeType(): Boolean {
return requireString(ContactsContract.Data.MIMETYPE) == ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE
}
fun Cursor.isNameMimeType(): Boolean {
return requireString(ContactsContract.Data.MIMETYPE) == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE
}
fun firstNonEmpty(s1: String?, s2: String): String {
return if (s1 != null && s1.isNotEmpty()) s1 else s2
}
}
data class ContactDetails(
val givenName: String?,
val familyName: String?,
val numbers: List<ContactPhoneDetails>
)
data class ContactPhoneDetails(
val contactUri: Uri,
val displayName: String?,
val photoUri: String?,
val number: String,
val type: Int,
val label: String?
)
data class NameDetails(
val displayName: String?,
val givenName: String?,
val familyName: String?,
val prefix: String?,
val suffix: String?,
val middleName: String?
)
data class PhoneDetails(
val number: String?,
val type: Int,
val label: String?
)
data class EmailDetails(
val address: String?,
val type: Int,
val label: String?
)
data class PostalAddressDetails(
val type: Int,
val label: String?,
val street: String?,
val poBox: String?,
val neighborhood: String?,
val city: String?,
val region: String?,
val postal: String?,
val country: String?
)
private data class LinkedContactDetails(
val id: Long,
val supportsVoice: String?,
val rawDisplayName: String?,
val aggregateDisplayName: String?,
val displayNameSource: Int
)
private data class SystemContactInfo(
val name: NameDetails,
val displayPhone: String,
val siblingRawContactId: Long,
val type: Int
)
private data class StructuredName(val givenName: String?, val familyName: String?)
}