kopia lustrzana https://github.com/ryukoposting/Signal-Android
835 wiersze
36 KiB
Kotlin
835 wiersze
36 KiB
Kotlin
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.displayName)
|
|
.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(),
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(phoneLookupId), null)?.use { idCursor ->
|
|
if (idCursor.moveToNext()) {
|
|
return SystemContactInfo(
|
|
displayName = contactCursor.requireString(ContactsContract.PhoneLookup.DISPLAY_NAME),
|
|
displayPhone = systemNumber,
|
|
siblingRawContactId = idCursor.requireLong(ContactsContract.RawContacts._ID),
|
|
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)
|
|
|
|
if (displayNumber != null && displayNumber.isNotEmpty()) {
|
|
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 = e164Formatter(displayNumber),
|
|
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 displayName: String?,
|
|
val displayPhone: String,
|
|
val siblingRawContactId: Long,
|
|
val type: Int
|
|
)
|
|
|
|
private data class StructuredName(val givenName: String?, val familyName: String?)
|
|
}
|