Improve contact sync for individual contacts.

This commit is contained in:
Greyson Parrelli 2022-04-22 07:47:49 -04:00 committed by Cody Henthorne
parent e2292dfa34
commit 5478285362
34 changed files with 431 additions and 547 deletions

View file

@ -8,10 +8,8 @@ import android.os.RemoteException
import android.text.TextUtils
import androidx.annotation.WorkerThread
import org.signal.contacts.ContactLinkConfiguration
import org.signal.contacts.SystemContactsRepository.addMessageAndCallLinksToContacts
import org.signal.contacts.SystemContactsRepository.getAllSystemContacts
import org.signal.contacts.SystemContactsRepository.getOrCreateSystemAccount
import org.signal.contacts.SystemContactsRepository.removeDeletedRawContactsForAccount
import org.signal.contacts.SystemContactsRepository
import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
@ -22,6 +20,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationUtil
@ -45,6 +44,7 @@ object ContactDiscovery {
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
@JvmStatic
@Throws(IOException::class)
@ -133,6 +133,10 @@ object ContactDiscovery {
syncRecipientsWithSystemContacts(context, emptyMap())
}
private fun phoneNumberFormatter(context: Context): (String) -> String {
return { PhoneNumberFormatter.get(context).format(it) }
}
private fun refreshRecipients(
context: Context,
descriptor: String,
@ -152,7 +156,23 @@ object ContactDiscovery {
addSystemContactLinks(context, result.registeredIds, removeSystemContactLinksIfMissing)
stopwatch.split("contact-links")
syncRecipientsWithSystemContacts(context, result.rewrites)
syncRecipientsWithSystemContacts(
context = context,
rewrites = result.rewrites,
contactsProvider = {
if (result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD) {
Log.d(TAG, "Doing a full system contact sync because there are ${result.registeredIds.size} contacts to get info for.")
SystemContactsRepository.getAllSystemContacts(context, phoneNumberFormatter(context))
} else {
Log.d(TAG, "Doing a partial system contact sync because there are ${result.registeredIds.size} contacts to get info for.")
SystemContactsRepository.getContactDetailsByQueries(
context = context,
queries = Recipient.resolvedList(result.registeredIds).mapNotNull { it.e164.orElse(null) },
e164Formatter = phoneNumberFormatter(context)
)
}
}
)
stopwatch.split("contact-sync")
if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context) && notifyOfNewUsers) {
@ -227,7 +247,7 @@ object ContactDiscovery {
val stopwatch = Stopwatch("contact-links")
val account = getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name))
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
@ -237,10 +257,10 @@ object ContactDiscovery {
val registeredE164s: Set<String> = SignalDatabase.recipients.getE164sForIds(registeredIds)
stopwatch.split("fetch-e164s")
removeDeletedRawContactsForAccount(context, account)
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account)
stopwatch.split("delete-stragglers")
addMessageAndCallLinksToContacts(
SystemContactsRepository.addMessageAndCallLinksToContacts(
context = context,
config = buildContactLinkConfiguration(context, account),
targetE164s = registeredE164s,
@ -259,30 +279,38 @@ object ContactDiscovery {
/**
* Synchronizes info from the system contacts (name, avatar, etc)
*/
private fun syncRecipientsWithSystemContacts(context: Context, rewrites: Map<String, String>) {
private fun syncRecipientsWithSystemContacts(
context: Context,
rewrites: Map<String, String>,
contactsProvider: () -> SystemContactsRepository.ContactIterator = { SystemContactsRepository.getAllSystemContacts(context, phoneNumberFormatter(context)) }
) {
val handle = SignalDatabase.recipients.beginBulkSystemContactUpdate()
try {
getAllSystemContacts(context) { PhoneNumberFormatter.get(context).format(it) }.use { iterator ->
contactsProvider().use { iterator ->
while (iterator.hasNext()) {
val details = iterator.next()
val name = StructuredNameRecord(details.givenName, details.familyName)
val phones = details.numbers
.map { phoneDetails ->
val realNumber = Util.getFirstNonEmpty(rewrites[phoneDetails.number], phoneDetails.number)
PhoneNumberRecord.Builder()
.withRecipientId(Recipient.externalContact(context, realNumber).id)
.withContactUri(phoneDetails.contactUri)
.withDisplayName(phoneDetails.displayName)
.withContactPhotoUri(phoneDetails.photoUri)
.withContactLabel(phoneDetails.label)
.build()
}
.toList()
ContactHolder().apply {
setStructuredNameRecord(name)
addPhoneNumberRecords(phones)
}.commit(handle)
for (phoneDetails in details.numbers) {
val realNumber: String = Util.getFirstNonEmpty(rewrites[phoneDetails.number], phoneDetails.number)
val profileName: ProfileName = if (!StringUtil.isEmpty(details.givenName)) {
ProfileName.fromParts(details.givenName, details.familyName)
} else if (!StringUtil.isEmpty(phoneDetails.displayName)) {
ProfileName.asGiven(phoneDetails.displayName)
} else {
ProfileName.EMPTY
}
handle.setSystemContactInfo(
Recipient.externalContact(context, realNumber).id,
profileName,
phoneDetails.displayName,
phoneDetails.photoUri,
phoneDetails.label,
phoneDetails.type,
phoneDetails.contactUri.toString()
)
}
}
}
} catch (e: IllegalStateException) {

View file

@ -1,54 +0,0 @@
package org.thoughtcrime.securesms.contacts.sync;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.profiles.ProfileName;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
final class ContactHolder {
private static final String TAG = Log.tag(ContactHolder.class);
private final List<PhoneNumberRecord> phoneNumberRecords = new LinkedList<>();
private StructuredNameRecord structuredNameRecord;
public void addPhoneNumberRecords(@NonNull List<PhoneNumberRecord> phoneNumberRecords) {
this.phoneNumberRecords.addAll(phoneNumberRecords);
}
public void setStructuredNameRecord(@NonNull StructuredNameRecord structuredNameRecord) {
this.structuredNameRecord = structuredNameRecord;
}
void commit(@NonNull RecipientDatabase.BulkOperationsHandle handle) {
for (PhoneNumberRecord phoneNumberRecord : phoneNumberRecords) {
handle.setSystemContactInfo(phoneNumberRecord.getRecipientId(),
getProfileName(phoneNumberRecord.getDisplayName()),
phoneNumberRecord.getDisplayName(),
phoneNumberRecord.getContactPhotoUri(),
phoneNumberRecord.getContactLabel(),
phoneNumberRecord.getPhoneType(),
Optional.ofNullable(phoneNumberRecord.getContactUri()).map(Uri::toString).orElse(null));
}
}
private @NonNull ProfileName getProfileName(@Nullable String displayName) {
if (structuredNameRecord != null && structuredNameRecord.hasGivenName()) {
return structuredNameRecord.asProfileName();
} else if (displayName != null) {
return ProfileName.asGiven(displayName);
} else {
Log.w(TAG, "Failed to find a suitable display name!");
return ProfileName.EMPTY;
}
}
}

View file

@ -1,99 +0,0 @@
package org.thoughtcrime.securesms.contacts.sync;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
/**
* Represents all the data we pull from a Phone data cursor row from the contacts database.
*/
final class PhoneNumberRecord {
private final RecipientId recipientId;
private final String displayName;
private final String contactPhotoUri;
private final String contactLabel;
private final int phoneType;
private final Uri contactUri;
private PhoneNumberRecord(@NonNull PhoneNumberRecord.Builder builder) {
recipientId = Objects.requireNonNull(builder.recipientId);
displayName = builder.displayName;
contactPhotoUri = builder.contactPhotoUri;
contactLabel = builder.contactLabel;
phoneType = builder.phoneType;
contactUri = builder.contactUri;
}
@NonNull RecipientId getRecipientId() {
return recipientId;
}
@Nullable String getDisplayName() {
return displayName;
}
@Nullable String getContactPhotoUri() {
return contactPhotoUri;
}
@Nullable String getContactLabel() {
return contactLabel;
}
int getPhoneType() {
return phoneType;
}
@Nullable Uri getContactUri() {
return contactUri;
}
final static class Builder {
private RecipientId recipientId;
private String displayName;
private String contactPhotoUri;
private String contactLabel;
private int phoneType;
private Uri contactUri;
@NonNull Builder withRecipientId(@NonNull RecipientId recipientId) {
this.recipientId = recipientId;
return this;
}
@NonNull Builder withDisplayName(@Nullable String displayName) {
this.displayName = displayName;
return this;
}
@NonNull Builder withContactUri(@Nullable Uri contactUri) {
this.contactUri = contactUri;
return this;
}
@NonNull Builder withContactLabel(@Nullable String contactLabel) {
this.contactLabel = contactLabel;
return this;
}
@NonNull Builder withContactPhotoUri(@Nullable String contactPhotoUri) {
this.contactPhotoUri = contactPhotoUri;
return this;
}
@NonNull Builder withPhoneType(int phoneType) {
this.phoneType = phoneType;
return this;
}
@NonNull PhoneNumberRecord build() {
return new PhoneNumberRecord(this);
}
}
}

View file

@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.contacts.sync;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.profiles.ProfileName;
/**
* Represents the data pulled from a StructuredName row of a Contacts data cursor.
*/
final class StructuredNameRecord {
private final String givenName;
private final String familyName;
public StructuredNameRecord(@Nullable String givenName, @Nullable String familyName) {
this.givenName = givenName;
this.familyName = familyName;
}
public boolean hasGivenName() {
return givenName != null;
}
public @NonNull ProfileName asProfileName() {
return ProfileName.fromParts(givenName, familyName);
}
}

View file

@ -20,7 +20,11 @@
</activity>
<activity
android:name=".ContactsActivity"
android:name=".ContactListActivity"
android:exported="false" />
<activity
android:name=".ContactLookupActivity"
android:exported="false" />
<service

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

@ -2,6 +2,7 @@ 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
@ -11,10 +12,10 @@ import org.signal.contacts.SystemContactsRepository.ContactIterator
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
class ContactsViewModel(application: Application) : AndroidViewModel(application) {
class ContactListViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(ContactsViewModel::class.java)
private val TAG = Log.tag(ContactListViewModel::class.java)
}
private val _contacts: MutableLiveData<List<ContactDetails>> = MutableLiveData()
@ -30,16 +31,20 @@ class ContactsViewModel(application: Application) : AndroidViewModel(application
accountDisplayName = "Test"
)
val startTime: Long = System.currentTimeMillis()
if (account != null) {
val contactList: List<ContactDetails> = SystemContactsRepository.getAllSystemContacts(
context = application,
e164Formatter = { number -> number }
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.")
}
}

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

@ -1,117 +0,0 @@
package org.signal.contactstest
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
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.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
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.ContactDetails
import org.signal.contacts.SystemContactsRepository.ContactPhoneDetails
class ContactsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_contacts)
val list: RecyclerView = findViewById(R.id.list)
val adapter = ContactsAdapter()
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
val viewModel: ContactsViewModel by viewModels()
viewModel.contacts.observe(this) { adapter.submitList(it) }
}
private inner class ContactsAdapter : ListAdapter<ContactDetails, ContactViewHolder>(object : DiffUtil.ItemCallback<ContactDetails>() {
override fun areItemsTheSame(oldItem: ContactDetails, newItem: ContactDetails): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: ContactDetails, newItem: 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))
}
}
private inner class ContactViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val givenName: TextView = itemView.findViewById(R.id.given_name)
val familyName: TextView = itemView.findViewById(R.id.family_name)
val phoneAdapter: PhoneAdapter = PhoneAdapter()
val phoneList: RecyclerView = itemView.findViewById<RecyclerView?>(R.id.phone_list).apply {
layoutManager = LinearLayoutManager(itemView.context)
adapter = phoneAdapter
}
fun bind(contact: ContactDetails) {
givenName.text = "Given Name: ${contact.givenName}"
familyName.text = "Family Name: ${contact.familyName}"
phoneAdapter.submitList(contact.numbers)
}
}
private inner class PhoneAdapter : ListAdapter<ContactPhoneDetails, PhoneViewHolder>(object : DiffUtil.ItemCallback<ContactPhoneDetails>() {
override fun areItemsTheSame(oldItem: ContactPhoneDetails, newItem: ContactPhoneDetails): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: ContactPhoneDetails, newItem: 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))
}
}
private inner class PhoneViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val photo: ImageView = itemView.findViewById(R.id.contact_photo)
val displayName: TextView = itemView.findViewById(R.id.display_name)
val number: TextView = itemView.findViewById(R.id.number)
val type: TextView = itemView.findViewById(R.id.type)
val goButton: View = itemView.findViewById(R.id.go_button)
fun bind(details: 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 {
startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = details.contactUri
}
)
}
}
}
}

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

@ -30,8 +30,15 @@ class MainActivity : AppCompatActivity() {
findViewById<Button>(R.id.contact_list_button).setOnClickListener { v ->
if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
startActivity(Intent(this, ContactsActivity::class.java))
finish()
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)
}
@ -92,12 +99,13 @@ class MainActivity : AppCompatActivity() {
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, ContactsActivity::class.java))
finish()
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 {

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

@ -1,31 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -1,171 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

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,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

@ -11,12 +11,23 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Contact List"
app:layout_constraintBottom_toTopOf="@id/link_contacts_button"
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"
@ -25,7 +36,7 @@
app:layout_constraintBottom_toTopOf="@id/unlink_contact_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contact_list_button" />
app:layout_constraintTop_toBottomOf="@id/contact_lookup_button" />
<Button
android:id="@+id/unlink_contact_button"

View file

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

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

View file

@ -2,8 +2,8 @@
<!-- Base application theme. -->
<style name="Theme.ContactsTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<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>

View file

@ -38,7 +38,7 @@ object SystemContactsRepository {
@JvmStatic
fun getAllSystemContacts(context: Context, e164Formatter: (String) -> String): ContactIterator {
val uri = ContactsContract.Data.CONTENT_URI
val projection = SqlUtil.buildArgs(
val projection = arrayOf(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
@ -59,6 +59,50 @@ object SystemContactsRepository {
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)
*/