Improve contact pull-to-refresh performance.
This commit is contained in:
parent
2cfa685ae2
commit
4077dc829a
10 changed files with 229 additions and 89 deletions
|
@ -1,24 +1,19 @@
|
||||||
package org.thoughtcrime.securesms.contacts.sync
|
package org.thoughtcrime.securesms.contacts.sync
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.accounts.Account
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.OperationApplicationException
|
|
||||||
import android.os.RemoteException
|
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import org.signal.contacts.ContactLinkConfiguration
|
|
||||||
import org.signal.contacts.SystemContactsRepository
|
import org.signal.contacts.SystemContactsRepository
|
||||||
import org.signal.contacts.SystemContactsRepository.ContactIterator
|
import org.signal.contacts.SystemContactsRepository.ContactIterator
|
||||||
import org.signal.contacts.SystemContactsRepository.ContactPhoneDetails
|
import org.signal.contacts.SystemContactsRepository.ContactPhoneDetails
|
||||||
import org.signal.core.util.Stopwatch
|
import org.signal.core.util.Stopwatch
|
||||||
import org.signal.core.util.StringUtil
|
import org.signal.core.util.StringUtil
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.BuildConfig
|
|
||||||
import org.thoughtcrime.securesms.R
|
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.jobs.SyncSystemContactLinksJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||||
|
@ -45,9 +40,6 @@ object ContactDiscovery {
|
||||||
|
|
||||||
private val TAG = Log.tag(ContactDiscovery::class.java)
|
private val TAG = Log.tag(ContactDiscovery::class.java)
|
||||||
|
|
||||||
private const val MESSAGE_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
|
||||||
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
|
||||||
private const val CONTACT_TAG = "__TS"
|
|
||||||
private const val FULL_SYSTEM_CONTACT_SYNC_THRESHOLD = 3
|
private const val FULL_SYSTEM_CONTACT_SYNC_THRESHOLD = 3
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
@ -154,8 +146,7 @@ object ContactDiscovery {
|
||||||
stopwatch.split("cds")
|
stopwatch.split("cds")
|
||||||
|
|
||||||
if (hasContactsPermissions(context)) {
|
if (hasContactsPermissions(context)) {
|
||||||
addSystemContactLinks(context, result.registeredIds, removeSystemContactLinksIfMissing)
|
ApplicationDependencies.getJobManager().add(SyncSystemContactLinksJob())
|
||||||
stopwatch.split("contact-links")
|
|
||||||
|
|
||||||
val useFullSync = removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD
|
val useFullSync = removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD
|
||||||
syncRecipientsWithSystemContacts(
|
syncRecipientsWithSystemContacts(
|
||||||
|
@ -215,70 +206,10 @@ object ContactDiscovery {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildContactLinkConfiguration(context: Context, account: Account): ContactLinkConfiguration {
|
|
||||||
return ContactLinkConfiguration(
|
|
||||||
account = account,
|
|
||||||
appName = context.getString(R.string.app_name),
|
|
||||||
messagePrompt = { e164 -> context.getString(R.string.ContactsDatabase_message_s, e164) },
|
|
||||||
callPrompt = { e164 -> context.getString(R.string.ContactsDatabase_signal_call_s, e164) },
|
|
||||||
e164Formatter = { number -> PhoneNumberFormatter.get(context).format(number) },
|
|
||||||
messageMimetype = MESSAGE_MIMETYPE,
|
|
||||||
callMimetype = CALL_MIMETYPE,
|
|
||||||
syncTag = CONTACT_TAG
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasContactsPermissions(context: Context): Boolean {
|
private fun hasContactsPermissions(context: Context): Boolean {
|
||||||
return Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)
|
return Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the "Message/Call $number with Signal" link to registered users in the system contacts.
|
|
||||||
* @param registeredIds A list of registered [RecipientId]s
|
|
||||||
* @param removeIfMissing If true, this will remove links from every currently-linked system contact that is *not* in the [registeredIds] list.
|
|
||||||
*/
|
|
||||||
private fun addSystemContactLinks(context: Context, registeredIds: Collection<RecipientId>, removeIfMissing: Boolean) {
|
|
||||||
if (!Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
|
||||||
Log.w(TAG, "[addSystemContactLinks] No contact permissions. Skipping.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (registeredIds.isEmpty()) {
|
|
||||||
Log.w(TAG, "[addSystemContactLinks] No registeredIds. Skipping.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val stopwatch = Stopwatch("contact-links")
|
|
||||||
|
|
||||||
val account = SystemContactsRepository.getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name))
|
|
||||||
if (account == null) {
|
|
||||||
Log.w(TAG, "[addSystemContactLinks] Failed to create an account!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val registeredE164s: Set<String> = SignalDatabase.recipients.getE164sForIds(registeredIds)
|
|
||||||
stopwatch.split("fetch-e164s")
|
|
||||||
|
|
||||||
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account)
|
|
||||||
stopwatch.split("delete-stragglers")
|
|
||||||
|
|
||||||
SystemContactsRepository.addMessageAndCallLinksToContacts(
|
|
||||||
context = context,
|
|
||||||
config = buildContactLinkConfiguration(context, account),
|
|
||||||
targetE164s = registeredE164s,
|
|
||||||
removeIfMissing = removeIfMissing
|
|
||||||
)
|
|
||||||
stopwatch.split("add-links")
|
|
||||||
|
|
||||||
stopwatch.stop(TAG)
|
|
||||||
} catch (e: RemoteException) {
|
|
||||||
Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e)
|
|
||||||
} catch (e: OperationApplicationException) {
|
|
||||||
Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes info from the system contacts (name, avatar, etc)
|
* Synchronizes info from the system contacts (name, avatar, etc)
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -172,7 +172,10 @@ object ContactDiscoveryRefreshV2 {
|
||||||
stopwatch.split("process-result")
|
stopwatch.split("process-result")
|
||||||
|
|
||||||
val existingIds: Set<RecipientId> = SignalDatabase.recipients.getAllPossiblyRegisteredByE164(recipientE164s + rewrites.values)
|
val existingIds: Set<RecipientId> = SignalDatabase.recipients.getAllPossiblyRegisteredByE164(recipientE164s + rewrites.values)
|
||||||
|
stopwatch.split("get-ids")
|
||||||
|
|
||||||
val inactiveIds: Set<RecipientId> = (existingIds - registeredIds).removeRegisteredButUnlisted()
|
val inactiveIds: Set<RecipientId> = (existingIds - registeredIds).removeRegisteredButUnlisted()
|
||||||
|
stopwatch.split("registered-but-unlisted")
|
||||||
|
|
||||||
SignalDatabase.recipients.bulkUpdatedRegisteredStatus(aciMap, inactiveIds)
|
SignalDatabase.recipients.bulkUpdatedRegisteredStatus(aciMap, inactiveIds)
|
||||||
stopwatch.split("update-registered")
|
stopwatch.split("update-registered")
|
||||||
|
|
|
@ -22,6 +22,7 @@ import org.signal.core.util.optionalInt
|
||||||
import org.signal.core.util.optionalLong
|
import org.signal.core.util.optionalLong
|
||||||
import org.signal.core.util.optionalString
|
import org.signal.core.util.optionalString
|
||||||
import org.signal.core.util.or
|
import org.signal.core.util.or
|
||||||
|
import org.signal.core.util.readToSet
|
||||||
import org.signal.core.util.requireBlob
|
import org.signal.core.util.requireBlob
|
||||||
import org.signal.core.util.requireBoolean
|
import org.signal.core.util.requireBoolean
|
||||||
import org.signal.core.util.requireInt
|
import org.signal.core.util.requireInt
|
||||||
|
@ -2201,12 +2202,12 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkUpdatedRegisteredStatus(registered: Map<RecipientId, ServiceId?>, unregistered: Collection<RecipientId>) {
|
fun bulkUpdatedRegisteredStatus(registered: Map<RecipientId, ServiceId?>, unregistered: Collection<RecipientId>) {
|
||||||
val db = writableDatabase
|
writableDatabase.withinTransaction { db ->
|
||||||
|
val registeredWithServiceId: Set<RecipientId> = getRegisteredWithServiceIds()
|
||||||
|
val needsMarkRegistered: Map<RecipientId, ServiceId?> = registered - registeredWithServiceId
|
||||||
|
|
||||||
db.beginTransaction()
|
for ((recipientId, serviceId) in needsMarkRegistered) {
|
||||||
try {
|
val values = ContentValues().apply {
|
||||||
for ((recipientId, serviceId) in registered) {
|
|
||||||
val values = ContentValues(2).apply {
|
|
||||||
put(REGISTERED, RegisteredState.REGISTERED.id)
|
put(REGISTERED, RegisteredState.REGISTERED.id)
|
||||||
put(UNREGISTERED_TIMESTAMP, 0)
|
put(UNREGISTERED_TIMESTAMP, 0)
|
||||||
if (serviceId != null) {
|
if (serviceId != null) {
|
||||||
|
@ -2236,10 +2237,6 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||||
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
|
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.setTransactionSuccessful()
|
|
||||||
} finally {
|
|
||||||
db.endTransaction()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2907,6 +2904,17 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRegisteredWithServiceIds(): Set<RecipientId> {
|
||||||
|
return readableDatabase
|
||||||
|
.select(ID)
|
||||||
|
.from(TABLE_NAME)
|
||||||
|
.where("$REGISTERED = ? and $HIDDEN = ? AND $SERVICE_ID NOT NULL", 1, 0)
|
||||||
|
.run()
|
||||||
|
.readToSet { cursor ->
|
||||||
|
RecipientId.from(cursor.requireLong(ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getSystemContacts(): List<RecipientId> {
|
fun getSystemContacts(): List<RecipientId> {
|
||||||
val results: MutableList<RecipientId> = LinkedList()
|
val results: MutableList<RecipientId> = LinkedList()
|
||||||
|
|
||||||
|
@ -2919,6 +2927,17 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRegisteredE164s(): Set<String> {
|
||||||
|
return readableDatabase
|
||||||
|
.select(PHONE)
|
||||||
|
.from(TABLE_NAME)
|
||||||
|
.where("$REGISTERED = ? and $HIDDEN = ? AND $PHONE NOT NULL", 1, 0)
|
||||||
|
.run()
|
||||||
|
.readToSet { cursor ->
|
||||||
|
cursor.requireNonNullString(PHONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We no longer automatically generate a chat color. This method is used only
|
* We no longer automatically generate a chat color. This method is used only
|
||||||
* in the case of a legacy migration and otherwise should not be called.
|
* in the case of a legacy migration and otherwise should not be called.
|
||||||
|
|
|
@ -175,6 +175,7 @@ public final class JobManagerFactories {
|
||||||
put(SendReadReceiptJob.KEY, new SendReadReceiptJob.Factory(application));
|
put(SendReadReceiptJob.KEY, new SendReadReceiptJob.Factory(application));
|
||||||
put(SendRetryReceiptJob.KEY, new SendRetryReceiptJob.Factory());
|
put(SendRetryReceiptJob.KEY, new SendRetryReceiptJob.Factory());
|
||||||
put(SendViewedReceiptJob.KEY, new SendViewedReceiptJob.Factory(application));
|
put(SendViewedReceiptJob.KEY, new SendViewedReceiptJob.Factory(application));
|
||||||
|
put(SyncSystemContactLinksJob.KEY, new SyncSystemContactLinksJob.Factory());
|
||||||
put(MultiDeviceStorySendSyncJob.KEY, new MultiDeviceStorySendSyncJob.Factory());
|
put(MultiDeviceStorySendSyncJob.KEY, new MultiDeviceStorySendSyncJob.Factory());
|
||||||
put(ServiceOutageDetectionJob.KEY, new ServiceOutageDetectionJob.Factory());
|
put(ServiceOutageDetectionJob.KEY, new ServiceOutageDetectionJob.Factory());
|
||||||
put(SmsReceiveJob.KEY, new SmsReceiveJob.Factory());
|
put(SmsReceiveJob.KEY, new SmsReceiveJob.Factory());
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
package org.thoughtcrime.securesms.jobs
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.OperationApplicationException
|
||||||
|
import android.os.RemoteException
|
||||||
|
import org.signal.contacts.ContactLinkConfiguration
|
||||||
|
import org.signal.contacts.SystemContactsRepository
|
||||||
|
import org.signal.core.util.Stopwatch
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.BuildConfig
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Data
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job
|
||||||
|
import org.thoughtcrime.securesms.permissions.Permissions
|
||||||
|
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||||
|
import java.lang.Exception
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This job makes sure all of the contact "links" are up-to-date. The links are the actions you see when you look at a Signal user in your system contacts
|
||||||
|
* that let you send a message or start a call.
|
||||||
|
*/
|
||||||
|
class SyncSystemContactLinksJob private constructor(parameters: Parameters) : BaseJob(parameters) {
|
||||||
|
|
||||||
|
constructor() : this(
|
||||||
|
Parameters.Builder()
|
||||||
|
.setQueue("SyncSystemContactLinksJob")
|
||||||
|
.setMaxAttempts(1)
|
||||||
|
.setMaxInstancesForQueue(2)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun serialize(): Data = Data.EMPTY
|
||||||
|
override fun getFactoryKey() = KEY
|
||||||
|
override fun onFailure() = Unit
|
||||||
|
override fun onShouldRetry(e: Exception) = false
|
||||||
|
|
||||||
|
override fun onRun() {
|
||||||
|
if (!Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
||||||
|
Log.w(TAG, "No contact permissions. Skipping.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val stopwatch = Stopwatch("contact-links")
|
||||||
|
|
||||||
|
val registeredE164s: Set<String> = SignalDatabase.recipients.getRegisteredE164s()
|
||||||
|
|
||||||
|
if (registeredE164s.isEmpty()) {
|
||||||
|
Log.w(TAG, "No registeredE164s. Skipping.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.split("fetch-e164s")
|
||||||
|
|
||||||
|
val account = SystemContactsRepository.getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name))
|
||||||
|
if (account == null) {
|
||||||
|
Log.w(TAG, "Failed to create an account!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account)
|
||||||
|
stopwatch.split("delete-stragglers")
|
||||||
|
|
||||||
|
SystemContactsRepository.addMessageAndCallLinksToContacts(
|
||||||
|
context = context,
|
||||||
|
config = buildContactLinkConfiguration(context, account),
|
||||||
|
targetE164s = registeredE164s,
|
||||||
|
removeIfMissing = true
|
||||||
|
)
|
||||||
|
stopwatch.split("add-links")
|
||||||
|
|
||||||
|
stopwatch.stop(TAG)
|
||||||
|
} catch (e: RemoteException) {
|
||||||
|
Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e)
|
||||||
|
} catch (e: OperationApplicationException) {
|
||||||
|
Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContactLinkConfiguration(context: Context, account: Account): ContactLinkConfiguration {
|
||||||
|
return ContactLinkConfiguration(
|
||||||
|
account = account,
|
||||||
|
appName = context.getString(R.string.app_name),
|
||||||
|
messagePrompt = { e164 -> context.getString(R.string.ContactsDatabase_message_s, e164) },
|
||||||
|
callPrompt = { e164 -> context.getString(R.string.ContactsDatabase_signal_call_s, e164) },
|
||||||
|
e164Formatter = { number -> PhoneNumberFormatter.get(context).format(number) },
|
||||||
|
messageMimetype = MESSAGE_MIMETYPE,
|
||||||
|
callMimetype = CALL_MIMETYPE,
|
||||||
|
syncTag = CONTACT_TAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Job.Factory<SyncSystemContactLinksJob> {
|
||||||
|
override fun create(parameters: Parameters, data: Data) = SyncSystemContactLinksJob(parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(SyncSystemContactLinksJob::class.java)
|
||||||
|
|
||||||
|
const val KEY = "SyncSystemContactLinksJob"
|
||||||
|
|
||||||
|
private const val MESSAGE_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||||
|
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
||||||
|
private const val CONTACT_TAG = "__TS"
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
findViewById<Button>(R.id.link_contacts_button).setOnClickListener { v ->
|
findViewById<Button>(R.id.link_contacts_button).setOnClickListener { v ->
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
||||||
SimpleTask.run({
|
SimpleTask.run({
|
||||||
val allE164s: Set<String> = SystemContactsRepository.getAllDisplayNumbers(this).map { PhoneNumberUtils.formatNumberToE164(it, "US") }.toSet()
|
val allE164s: Set<String> = SystemContactsRepository.getAllDisplayNumbers(this).map { PhoneNumberUtils.formatNumberToE164(it, "US") }.toSet()
|
||||||
|
@ -60,7 +61,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
return@run true
|
return@run true
|
||||||
}, { success ->
|
}, { success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
Toast.makeText(this, "Success!", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "Success! Took ${System.currentTimeMillis() - startTime} ms", Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
@ -71,6 +72,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
findViewById<Button>(R.id.unlink_contact_button).setOnClickListener { v ->
|
findViewById<Button>(R.id.unlink_contact_button).setOnClickListener { v ->
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
||||||
SimpleTask.run({
|
SimpleTask.run({
|
||||||
val account: Account = SystemContactsRepository.getOrCreateSystemAccount(this, BuildConfig.APPLICATION_ID, "Contact Test") ?: return@run false
|
val account: Account = SystemContactsRepository.getOrCreateSystemAccount(this, BuildConfig.APPLICATION_ID, "Contact Test") ?: return@run false
|
||||||
|
@ -85,7 +87,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
return@run true
|
return@run true
|
||||||
}, { success ->
|
}, { success ->
|
||||||
if (success) {
|
if (success) {
|
||||||
Toast.makeText(this, "Success!", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "Success! Took ${System.currentTimeMillis() - startTime} ms", Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "Failed to create account!", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,4 +48,5 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/link_contacts_button" />
|
app:layout_constraintTop_toBottomOf="@id/link_contacts_button" />
|
||||||
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -22,6 +22,58 @@ import java.util.Objects
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A way to retrieve and update data in the Android system contacts.
|
* 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 {
|
object SystemContactsRepository {
|
||||||
|
|
||||||
|
@ -32,8 +84,10 @@ object SystemContactsRepository {
|
||||||
private const val FIELD_SUPPORTS_VOICE = ContactsContract.RawContacts.SYNC4
|
private const val FIELD_SUPPORTS_VOICE = ContactsContract.RawContacts.SYNC4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets and returns a cursor of data for all contacts, containing both phone number data and
|
* Gets and returns an iterator over data for all contacts, containing both phone number data and structured name data.
|
||||||
* 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
|
@JvmStatic
|
||||||
fun getAllSystemContacts(context: Context, e164Formatter: (String) -> String): ContactIterator {
|
fun getAllSystemContacts(context: Context, e164Formatter: (String) -> String): ContactIterator {
|
||||||
|
@ -423,6 +477,7 @@ object SystemContactsRepository {
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
|
// RawContact entry
|
||||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, linkConfig.account.name)
|
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, linkConfig.account.name)
|
||||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, linkConfig.account.type)
|
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, linkConfig.account.type)
|
||||||
|
@ -430,12 +485,14 @@ object SystemContactsRepository {
|
||||||
.withValue(FIELD_SUPPORTS_VOICE, true.toString())
|
.withValue(FIELD_SUPPORTS_VOICE, true.toString())
|
||||||
.build(),
|
.build(),
|
||||||
|
|
||||||
|
// Data entry for name
|
||||||
ContentProviderOperation.newInsert(dataUri)
|
ContentProviderOperation.newInsert(dataUri)
|
||||||
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, operationIndex)
|
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, operationIndex)
|
||||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, systemContactInfo.displayName)
|
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, systemContactInfo.displayName)
|
||||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||||
.build(),
|
.build(),
|
||||||
|
|
||||||
|
// Data entry for number (Note: This may not be necessary)
|
||||||
ContentProviderOperation.newInsert(dataUri)
|
ContentProviderOperation.newInsert(dataUri)
|
||||||
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, operationIndex)
|
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, operationIndex)
|
||||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||||
|
@ -444,6 +501,7 @@ object SystemContactsRepository {
|
||||||
.withValue(FIELD_TAG, linkConfig.syncTag)
|
.withValue(FIELD_TAG, linkConfig.syncTag)
|
||||||
.build(),
|
.build(),
|
||||||
|
|
||||||
|
// Data entry for sending a message
|
||||||
ContentProviderOperation.newInsert(dataUri)
|
ContentProviderOperation.newInsert(dataUri)
|
||||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
|
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
|
||||||
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.messageMimetype)
|
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.messageMimetype)
|
||||||
|
@ -453,6 +511,7 @@ object SystemContactsRepository {
|
||||||
.withYieldAllowed(true)
|
.withYieldAllowed(true)
|
||||||
.build(),
|
.build(),
|
||||||
|
|
||||||
|
// Data entry for making a call
|
||||||
ContentProviderOperation.newInsert(dataUri)
|
ContentProviderOperation.newInsert(dataUri)
|
||||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
|
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
|
||||||
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.callMimetype)
|
.withValue(ContactsContract.Data.MIMETYPE, linkConfig.callMimetype)
|
||||||
|
@ -462,8 +521,9 @@ object SystemContactsRepository {
|
||||||
.withYieldAllowed(true)
|
.withYieldAllowed(true)
|
||||||
.build(),
|
.build(),
|
||||||
|
|
||||||
|
// Ensures that this RawContact entry is shown next to another RawContact entry we found for this contact
|
||||||
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
|
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
|
||||||
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, systemContactInfo.rawContactId)
|
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, systemContactInfo.siblingRawContactId)
|
||||||
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, operationIndex)
|
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, operationIndex)
|
||||||
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
|
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
|
||||||
.build()
|
.build()
|
||||||
|
@ -522,12 +582,13 @@ object SystemContactsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSystemContactInfo(context: Context, e164: String, e164Formatter: (String) -> String): SystemContactInfo? {
|
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 uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(e164))
|
||||||
val projection = arrayOf(
|
val projection = arrayOf(
|
||||||
ContactsContract.PhoneLookup.NUMBER,
|
ContactsContract.PhoneLookup.NUMBER,
|
||||||
ContactsContract.PhoneLookup._ID,
|
ContactsContract.PhoneLookup._ID,
|
||||||
ContactsContract.PhoneLookup.DISPLAY_NAME,
|
ContactsContract.PhoneLookup.DISPLAY_NAME,
|
||||||
ContactsContract.PhoneLookup.TYPE
|
ContactsContract.PhoneLookup.TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
context.contentResolver.query(uri, projection, null, null, null)?.use { contactCursor ->
|
context.contentResolver.query(uri, projection, null, null, null)?.use { contactCursor ->
|
||||||
|
@ -541,7 +602,7 @@ object SystemContactsRepository {
|
||||||
return SystemContactInfo(
|
return SystemContactInfo(
|
||||||
displayName = contactCursor.requireString(ContactsContract.PhoneLookup.DISPLAY_NAME),
|
displayName = contactCursor.requireString(ContactsContract.PhoneLookup.DISPLAY_NAME),
|
||||||
displayPhone = systemNumber,
|
displayPhone = systemNumber,
|
||||||
rawContactId = idCursor.requireLong(ContactsContract.RawContacts._ID),
|
siblingRawContactId = idCursor.requireLong(ContactsContract.RawContacts._ID),
|
||||||
type = contactCursor.requireInt(ContactsContract.PhoneLookup.TYPE)
|
type = contactCursor.requireInt(ContactsContract.PhoneLookup.TYPE)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -765,7 +826,7 @@ object SystemContactsRepository {
|
||||||
private data class SystemContactInfo(
|
private data class SystemContactInfo(
|
||||||
val displayName: String?,
|
val displayName: String?,
|
||||||
val displayPhone: String,
|
val displayPhone: String,
|
||||||
val rawContactId: Long,
|
val siblingRawContactId: Long,
|
||||||
val type: Int
|
val type: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -91,4 +91,17 @@ inline fun <T> Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: (
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <T> Cursor.readToSet(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): Set<T> {
|
||||||
|
val set = mutableSetOf<T>()
|
||||||
|
use {
|
||||||
|
while (moveToNext()) {
|
||||||
|
val record = mapper(this)
|
||||||
|
if (predicate(record)) {
|
||||||
|
set += mapper(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
fun Boolean.toInt(): Int = if (this) 1 else 0
|
fun Boolean.toInt(): Int = if (this) 1 else 0
|
||||||
|
|
|
@ -127,7 +127,7 @@ public final class Log {
|
||||||
logger.e(tag, message, t, keepLonger);
|
logger.e(tag, message, t, keepLonger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String tag(Class<?> clazz) {
|
public static @NonNull String tag(Class<?> clazz) {
|
||||||
String simpleName = clazz.getSimpleName();
|
String simpleName = clazz.getSimpleName();
|
||||||
if (simpleName.length() > 23) {
|
if (simpleName.length() > 23) {
|
||||||
return simpleName.substring(0, 23);
|
return simpleName.substring(0, 23);
|
||||||
|
|
Loading…
Add table
Reference in a new issue