Restructure contacts search for plugins

This commit is contained in:
MM20
2025-04-05 17:18:22 +02:00
parent 04dd12ee96
commit cab93f87aa
22 changed files with 417 additions and 278 deletions
@@ -1,32 +1,21 @@
package de.mm20.launcher2.contacts
import android.content.ContentUris
import android.content.Context
import android.os.Build
import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.ktx.distinctByEquality
import de.mm20.launcher2.contacts.providers.AndroidContactProvider
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableRepository
import de.mm20.launcher2.search.contact.ContactInfoType
import de.mm20.launcher2.search.contact.CustomContactChannel
import de.mm20.launcher2.search.contact.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
internal class ContactRepository(
private val context: Context,
@@ -34,167 +23,7 @@ internal class ContactRepository(
private val settings: ContactSearchSettings,
) : SearchableRepository<Contact> {
fun get(id: Long): Flow<Contact?> = flow {
val rawContactsCursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
arrayOf(ContactsContract.RawContacts._ID),
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
arrayOf(id.toString()),
null
)
if (rawContactsCursor == null) {
emit(null)
return@flow
}
val rawContacts = mutableSetOf<Long>()
while (rawContactsCursor.moveToNext()) {
rawContacts.add(rawContactsCursor.getLong(0))
}
rawContactsCursor.close()
if (rawContacts.isEmpty()) {
emit(null)
return@flow
}
emit(getWithRawIds(id, rawContacts))
}
private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? =
withContext(Dispatchers.IO) {
val s = "${ContactsContract.Data.RAW_CONTACT_ID} IN (${rawIds.joinToString(", ")})"
val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
null, s, null, null
) ?: return@withContext null
var firstName: String? = null
var lastName: String? = null
var displayName: String? = null
val phoneNumbers = mutableListOf<PhoneNumber>()
val emailAddresses = mutableListOf<EmailAddress>()
val postalAddresses = mutableListOf<PostalAddress>()
val contactChannels = mutableListOf<CustomContactChannel>()
val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE)
val typeColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Contactables.TYPE)
val emailAddressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
val numberColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val addressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
val displayNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
val givenNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
val familyNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
val accountTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.ACCOUNT_TYPE_AND_DATA_SET)
val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3)
val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(emailAddressColumn)?.let {
emailAddresses += EmailAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Email.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Email.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Email.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(numberColumn)?.let { phone ->
phoneNumbers += PhoneNumber(
phone,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(addressColumn)?.let {
postalAddresses += PostalAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK -> ContactInfoType.Work
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
firstName = dataCursor.getStringOrNull(givenNameColumn)
lastName = dataCursor.getStringOrNull(familyNameColumn)
displayName = dataCursor.getStringOrNull(displayNameColumn)
}
else -> {
contactChannels += CustomContactChannel(
label = dataCursor.getStringOrNull(data3Column) ?: continue,
packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue,
mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue,
uri = ContentUris.withAppendedId(
ContactsContract.Data.CONTENT_URI,
dataCursor.getLongOrNull(idColumn) ?: continue
),
)
}
}
}
dataCursor.close()
val lookupKeyCursor = context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
"${ContactsContract.Contacts._ID} = ?",
arrayOf(id.toString()),
null
) ?: return@withContext null
var lookUpKey = ""
if (lookupKeyCursor.moveToNext()) {
lookUpKey = lookupKeyCursor.getString(0)
}
lookupKeyCursor.close()
val defaultCountryIso = context.resources.configuration.locales[0].country
return@withContext AndroidContact(
id = id,
name = displayName
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
?: return@withContext null,
phoneNumbers = phoneNumbers.sortedByDescending {
it.number.count { !PhoneNumberUtils.isReallyDialable(it) }
}.map {
val formattedNumber =
PhoneNumberUtils.formatNumber(it.number, defaultCountryIso)
?: return@map it
it.copy(number = formattedNumber)
}.distinctByEquality { a, b ->
if (Build.VERSION.SDK_INT < 31) {
PhoneNumberUtils.compare(context, a.number, b.number)
} else {
PhoneNumberUtils.areSamePhoneNumber(a.number, b.number, defaultCountryIso)
}
},
emailAddresses = emailAddresses.distinct(),
postalAddresses = postalAddresses.distinct(),
contactChannels = contactChannels.distinct(),
lookupKey = lookUpKey
)
}
override fun search(query: String, allowNetwork: Boolean): Flow<ImmutableList<Contact>> {
override fun search(query: String, allowNetwork: Boolean): Flow<List<Contact>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
if (query.length < 2) {
@@ -203,40 +32,28 @@ internal class ContactRepository(
}
}
return hasPermission.combine(settings.enabled) { perm, en -> perm && en }.map {
if (it) {
queryContacts(query)
} else {
persistentListOf()
return hasPermission.combineTransform(settings.enabledProviders) { perm, providerIds ->
val providers = providerIds.mapNotNull {
when (it) {
"local" -> if (perm) AndroidContactProvider(context) else null
else -> null
}
}
}
}
private suspend fun queryContacts(query: String): ImmutableList<Contact> {
val results = withContext(Dispatchers.IO) {
val proj = arrayOf(
ContactsContract.RawContacts.CONTACT_ID,
ContactsContract.RawContacts._ID
)
val sel =
"${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.RawContacts.DISPLAY_NAME_ALTERNATIVE} LIKE ? OR ${ContactsContract.RawContacts.PHONETIC_NAME} LIKE ? OR ${ContactsContract.RawContacts.SORT_KEY_PRIMARY} LIKE ?"
val selArgs = arrayOf("%$query%", "%$query%", "%$query%", "%$query%")
val cursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null
) ?: return@withContext mutableListOf()
//Maps raw contact ids to contact ids
val contactMap = mutableMapOf<Long, MutableSet<Long>>()
while (cursor.moveToNext()) {
contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1))
supervisorScope {
val result = MutableStateFlow(listOf<Contact>())
for (provider in providers) {
launch {
val r = provider.search(
query,
allowNetwork = allowNetwork,
)
result.update { it + r }
}
}
emitAll(result)
}
cursor.close()
val results = mutableListOf<Contact>()
for ((id, rawIds) in contactMap) {
getWithRawIds(id, rawIds)?.let { results.add(it) }
if (results.size > 15) break
}
results
}
return results.toImmutableList()
}
}
@@ -1,5 +1,8 @@
package de.mm20.launcher2.contacts
import android.content.Context
import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.contacts.providers.AndroidContactProvider
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
@@ -22,7 +25,7 @@ internal class ContactSerializer : SearchableSerializer {
}
internal class ContactDeserializer(
private val contactRepository: ContactRepository,
private val context: Context,
private val permissionsManager: PermissionsManager
) : SearchableDeserializer {
@@ -30,6 +33,8 @@ internal class ContactDeserializer(
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Contacts)) return null
val id = JSONObject(serialized).getLong("id")
return contactRepository.get(id).first()
val androidContactProvider = AndroidContactProvider(context)
return androidContactProvider.get(id)
}
}
@@ -1,5 +1,6 @@
package de.mm20.launcher2.contacts
import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
@@ -10,5 +11,5 @@ import org.koin.dsl.module
val contactsModule = module {
factory { ContactRepository(androidContext(), get(), get()) }
factory<SearchableRepository<Contact>>(named<Contact>()) { get<ContactRepository>() }
factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(get(), get()) }
factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(androidContext(), get()) }
}
@@ -1,4 +1,4 @@
package de.mm20.launcher2.contacts
package de.mm20.launcher2.contacts.providers
import android.content.ContentUris
import android.content.Context
@@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle
import android.provider.ContactsContract
import androidx.core.graphics.drawable.toDrawable
import de.mm20.launcher2.contacts.ContactSerializer
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.StaticIconLayer
@@ -14,7 +15,7 @@ import de.mm20.launcher2.ktx.asBitmap
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.contact.CustomContactChannel
import de.mm20.launcher2.search.contact.CustomContactAction
import de.mm20.launcher2.search.contact.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress
@@ -27,7 +28,7 @@ internal data class AndroidContact(
override val phoneNumbers: List<PhoneNumber>,
override val emailAddresses: List<EmailAddress>,
override val postalAddresses: List<PostalAddress>,
override val contactChannels: List<CustomContactChannel>, internal val lookupKey: String,
override val customActions: List<CustomContactAction>, internal val lookupKey: String,
override val labelOverride: String? = null,
) : Contact {
@@ -0,0 +1,223 @@
package de.mm20.launcher2.contacts.providers
import android.content.ContentUris
import android.content.Context
import android.os.Build
import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.ktx.distinctByEquality
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.contact.ContactInfoType
import de.mm20.launcher2.search.contact.CustomContactAction
import de.mm20.launcher2.search.contact.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* A contact provider that uses the Android ContactsContract API to search for contacts.
*/
class AndroidContactProvider(
private val context: Context,
) : ContactProvider {
override suspend fun search(
query: String,
allowNetwork: Boolean
): List<Contact> {
val results = withContext(Dispatchers.IO) {
val proj = arrayOf(
ContactsContract.RawContacts.CONTACT_ID,
ContactsContract.RawContacts._ID
)
val sel =
"${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.RawContacts.DISPLAY_NAME_ALTERNATIVE} LIKE ? OR ${ContactsContract.RawContacts.PHONETIC_NAME} LIKE ? OR ${ContactsContract.RawContacts.SORT_KEY_PRIMARY} LIKE ?"
val selArgs = arrayOf("%$query%", "%$query%", "%$query%", "%$query%")
val cursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null
) ?: return@withContext mutableListOf()
//Maps raw contact ids to contact ids
val contactMap = mutableMapOf<Long, MutableSet<Long>>()
while (cursor.moveToNext()) {
contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1))
}
cursor.close()
val results = mutableListOf<Contact>()
for ((id, rawIds) in contactMap) {
getWithRawIds(id, rawIds)?.let { results.add(it) }
if (results.size > 15) break
}
results
}
return results
}
/**
* Combine the given raw contact ids into a single contact.
*/
private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? =
withContext(Dispatchers.IO) {
val s = "${ContactsContract.Data.RAW_CONTACT_ID} IN (${rawIds.joinToString(", ")})"
val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
null, s, null, null
) ?: return@withContext null
var firstName: String? = null
var lastName: String? = null
var displayName: String? = null
val phoneNumbers = mutableListOf<PhoneNumber>()
val emailAddresses = mutableListOf<EmailAddress>()
val postalAddresses = mutableListOf<PostalAddress>()
val customActions = mutableListOf<CustomContactAction>()
val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE)
val typeColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Contactables.TYPE)
val emailAddressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
val numberColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val addressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
val displayNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
val givenNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
val familyNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
val accountTypeColumn =
dataCursor.getColumnIndex(ContactsContract.Data.ACCOUNT_TYPE_AND_DATA_SET)
val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3)
val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(emailAddressColumn)?.let {
emailAddresses += EmailAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Email.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Email.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Email.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(numberColumn)?.let { phone ->
phoneNumbers += PhoneNumber(
phone,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(addressColumn)?.let {
postalAddresses += PostalAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK -> ContactInfoType.Work
else -> ContactInfoType.Other
}
)
}
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
firstName = dataCursor.getStringOrNull(givenNameColumn)
lastName = dataCursor.getStringOrNull(familyNameColumn)
displayName = dataCursor.getStringOrNull(displayNameColumn)
}
else -> {
customActions += CustomContactAction(
label = dataCursor.getStringOrNull(data3Column) ?: continue,
packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue,
mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue,
uri = ContentUris.withAppendedId(
ContactsContract.Data.CONTENT_URI,
dataCursor.getLongOrNull(idColumn) ?: continue
),
)
}
}
}
dataCursor.close()
val lookupKeyCursor = context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
"${ContactsContract.Contacts._ID} = ?",
arrayOf(id.toString()),
null
) ?: return@withContext null
var lookUpKey = ""
if (lookupKeyCursor.moveToNext()) {
lookUpKey = lookupKeyCursor.getString(0)
}
lookupKeyCursor.close()
val defaultCountryIso = context.resources.configuration.locales[0].country
return@withContext AndroidContact(
id = id,
name = displayName
?: listOfNotNull(firstName, lastName).joinToString(" ")
.takeIf { it.isNotBlank() }
?: return@withContext null,
phoneNumbers = phoneNumbers.sortedByDescending {
it.number.count { !PhoneNumberUtils.isReallyDialable(it) }
}.map {
val formattedNumber =
PhoneNumberUtils.formatNumber(it.number, defaultCountryIso)
?: return@map it
it.copy(number = formattedNumber)
}.distinctByEquality { a, b ->
if (Build.VERSION.SDK_INT < 31) {
PhoneNumberUtils.compare(context, a.number, b.number)
} else {
PhoneNumberUtils.areSamePhoneNumber(a.number, b.number, defaultCountryIso)
}
},
emailAddresses = emailAddresses.distinct(),
postalAddresses = postalAddresses.distinct(),
customActions = customActions.distinct(),
lookupKey = lookUpKey
)
}
/**
* Get a contact by its id, or null if it doesn't exist.
*/
suspend fun get(id: Long): Contact? = withContext(Dispatchers.IO) {
val rawContactsCursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
arrayOf(ContactsContract.RawContacts._ID),
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
arrayOf(id.toString()),
null
)
if (rawContactsCursor == null) {
return@withContext null
}
val rawContacts = mutableSetOf<Long>()
while (rawContactsCursor.moveToNext()) {
rawContacts.add(rawContactsCursor.getLong(0))
}
rawContactsCursor.close()
if (rawContacts.isEmpty()) {
return@withContext null
}
return@withContext getWithRawIds(id, rawContacts)
}
}
@@ -0,0 +1,7 @@
package de.mm20.launcher2.contacts.providers
import de.mm20.launcher2.search.Contact
interface ContactProvider {
suspend fun search(query: String, allowNetwork: Boolean): List<Contact>
}