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
@@ -347,7 +347,7 @@ class SearchVM : ViewModel(), KoinComponent {
val missingContactsPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Contacts),
contactSearchSettings.enabled
contactSearchSettings.isProviderEnabled("local")
) { perm, enabled -> !perm && enabled }
fun requestContactsPermission(context: AppCompatActivity) {
@@ -355,7 +355,7 @@ class SearchVM : ViewModel(), KoinComponent {
}
fun disableContactsSearch() {
contactSearchSettings.setEnabled(false)
contactSearchSettings.setProviderEnabled("local", false)
}
val missingLocationPermission = combine(
@@ -2,7 +2,6 @@ package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.compose.animation.AnimatedContent
@@ -78,9 +77,6 @@ import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import androidx.core.net.toUri
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.getApplicationIconOrNull
@@ -276,7 +272,7 @@ fun ContactItem(
)
}
val apps = remember(contact) {
contact.contactChannels.groupBy { it.packageName }
contact.customActions.groupBy { it.packageName }
}
for ((i, app) in apps.entries.withIndex()) {
val packageName = app.key
@@ -34,10 +34,10 @@ class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.Contacts)
}
val localContacts = settings.enabled
val localContacts = settings.isProviderEnabled("local")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setLocalContacts(enabled: Boolean) {
settings.setEnabled(enabled)
settings.setProviderEnabled("local", enabled)
}
}
@@ -194,6 +194,7 @@ fun PluginSettingsScreen(pluginId: String) {
) {
Surface(
modifier = Modifier.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Column {
Row(
@@ -248,47 +249,6 @@ fun PluginSettingsScreen(pluginId: String) {
style = MaterialTheme.typography.bodyMedium,
)
}
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(bottom = 24.dp, start = 12.dp, end = 12.dp, top = 24.dp)
) {
for (type in types) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(end = 4.dp)
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Icon(
when (type) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
PluginType.Weather -> Icons.Rounded.LightMode
PluginType.LocationSearch -> Icons.Rounded.Place
PluginType.Calendar -> Icons.Rounded.Today
},
null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
when (type) {
PluginType.FileSearch -> stringResource(R.string.plugin_type_filesearch)
PluginType.Weather -> stringResource(R.string.plugin_type_weather)
PluginType.LocationSearch -> stringResource(R.string.plugin_type_locationsearch)
PluginType.Calendar -> stringResource(R.string.plugin_type_calendar)
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
}
@@ -55,11 +55,11 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val contacts = contactSearchSettings.enabled
val contacts = contactSearchSettings.isProviderEnabled("local")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setContacts(contacts: Boolean) {
contactSearchSettings.setEnabled(contacts)
contactSearchSettings.setProviderEnabled("local", contacts)
}
val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
@@ -1,14 +1,13 @@
package de.mm20.launcher2.search
import android.content.Context
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.icons.VectorLayer
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
@@ -18,7 +17,7 @@ interface Contact : SavableSearchable {
val phoneNumbers: List<PhoneNumber>
val emailAddresses: List<EmailAddress>
val postalAddresses: List<PostalAddress>
val contactChannels: List<CustomContactChannel>
val customActions: List<CustomContactAction>
val summary: String
get() {
@@ -3,6 +3,7 @@ package de.mm20.launcher2.preferences
import android.content.Context
import de.mm20.launcher2.preferences.migrations.Migration2
import de.mm20.launcher2.preferences.migrations.Migration3
import de.mm20.launcher2.preferences.migrations.Migration4
import de.mm20.launcher2.settings.BaseSettings
internal class LauncherDataStore(
@@ -14,6 +15,7 @@ internal class LauncherDataStore(
migrations = listOf(
Migration2(),
Migration3(),
Migration4(),
),
) {
@@ -52,7 +52,9 @@ data class LauncherSettingsData internal constructor(
val fileSearchProviders: Set<String> = setOf("local"),
@Deprecated("Use contactSearchProviders `local` instead")
val contactSearchEnabled: Boolean = true,
val contactSearchProviders: Set<String> = setOf("local"),
val contactSearchCallOnTap: Boolean = false,
@Deprecated("Use calendarSearchProviders `local` instead")
@@ -0,0 +1,23 @@
package de.mm20.launcher2.preferences.migrations
import androidx.datastore.core.DataMigration
import de.mm20.launcher2.preferences.LauncherSettingsData
class Migration4 : DataMigration<LauncherSettingsData> {
override suspend fun cleanUp() {
}
override suspend fun migrate(currentData: LauncherSettingsData): LauncherSettingsData {
return currentData.copy(
schemaVersion = 4,
contactSearchProviders = setOfNotNull(
if (currentData.contactSearchEnabled) "local" else null,
)
)
}
override suspend fun shouldMigrate(currentData: LauncherSettingsData): Boolean {
return currentData.schemaVersion < 4
}
}
@@ -6,11 +6,20 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
class ContactSearchSettings internal constructor(private val dataStore: LauncherDataStore) {
val enabled: Flow<Boolean>
get() = dataStore.data.map { it.contactSearchEnabled }.distinctUntilChanged()
fun setEnabled(enabled: Boolean) {
dataStore.update { it.copy(contactSearchEnabled = enabled) }
val enabledProviders: Flow<Set<String>>
get() = dataStore.data.map { it.contactSearchProviders }.distinctUntilChanged()
fun isProviderEnabled(provider: String) = dataStore.data.map { it.contactSearchProviders.contains(provider) }
fun setProviderEnabled(provider: String, enabled: Boolean) {
dataStore.update {
if (enabled) {
it.copy(contactSearchProviders = it.contactSearchProviders + provider)
} else {
it.copy(contactSearchProviders = it.contactSearchProviders - provider)
}
}
}
val callOnTap: Flow<Boolean>
@@ -5,4 +5,5 @@ enum class PluginType {
Weather,
LocationSearch,
Calendar,
ContactSearch,
}
@@ -1,6 +1,6 @@
package de.mm20.launcher2.plugin.contracts
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
@@ -21,7 +21,7 @@ abstract class ContactPluginContract {
val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers")
val EmailAddresses = column<List<EmailAddress>>("email_addresses")
val PostalAddresses = column<List<PostalAddress>>("postal_addresses")
val ContactChannels = column<List<CustomContactChannel>>("contact_channels")
val CustomActions = column<List<CustomContactAction>>("custom_actions")
val PhotoUri = column<String>("photo_uri")
}
@@ -26,7 +26,7 @@ data class PostalAddress(
* Custom contact channel, for example, WhatsApp message, Telegram video call, etc.
*/
@Serializable
data class CustomContactChannel(
data class CustomContactAction(
val label: String,
/**
* The data URI that is passed to the Intent.
@@ -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>
}
@@ -0,0 +1,43 @@
package de.mm20.launcher2.sdk.contacts
import android.net.Uri
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
data class Contact(
/**
* A unique and stable identifier for this contact.
*/
val id: String,
/**
* The display name for this contact.
* First name + last name, if applicable.
*/
val name: String,
/**
* List of phone numbers for this contact.
*/
val phoneNumbers: List<PhoneNumber> = emptyList(),
/**
* List of email addresses for this contact.
*/
val emailAddresses: List<EmailAddress> = emptyList(),
/**
* List of postal addresses for this contact.
*/
val postalAddresses: List<PostalAddress> = emptyList(),
/**
* List of additional actions to contact this person.
* For example, call on WhatsApp, send a message on Telegram, etc.
*/
val customActions: List<CustomContactAction> = emptyList(),
/**
* The URI of the contact's photo.
* This is a data URI, and the launcher will use it to display the contact's photo.
* If null, the launcher will use a default icon.
*/
val photoUri: Uri? = null,
)
@@ -0,0 +1,49 @@
package de.mm20.launcher2.sdk.contacts
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugin.config.QueryPluginConfig
import de.mm20.launcher2.plugin.contracts.ContactPluginContract.ContactColumns
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.plugin.data.buildCursor
import de.mm20.launcher2.plugin.data.get
import de.mm20.launcher2.sdk.base.QueryPluginProvider
abstract class ContactProvider(
config: QueryPluginConfig,
) : QueryPluginProvider<String, Contact>(config) {
override fun getQuery(uri: Uri): String? {
return uri.getQueryParameter(SearchPluginContract.Params.Query)
}
override fun List<Contact>.toCursor(): Cursor {
return buildCursor(ContactColumns, this) {
put(ContactColumns.Id, it.id)
put(ContactColumns.Name, it.name)
put(ContactColumns.PhoneNumbers, it.phoneNumbers)
put(ContactColumns.EmailAddresses, it.emailAddresses)
put(ContactColumns.PostalAddresses, it.postalAddresses)
put(ContactColumns.CustomActions, it.customActions)
put(ContactColumns.PhotoUri, it.photoUri?.toString())
}
}
override fun Bundle.toResult(): Contact? {
return Contact(
id = get(ContactColumns.Id) ?: return null,
name = get(ContactColumns.Name) ?: return null,
phoneNumbers = get(ContactColumns.PhoneNumbers) ?: emptyList(),
emailAddresses = get(ContactColumns.EmailAddresses) ?: emptyList(),
postalAddresses = get(ContactColumns.PostalAddresses) ?: emptyList(),
customActions = get(ContactColumns.CustomActions) ?: emptyList(),
photoUri = get(ContactColumns.PhotoUri)?.let { Uri.parse(it) },
)
}
final override fun getPluginType(): PluginType {
return PluginType.ContactSearch
}
}
@@ -70,6 +70,7 @@ internal class SearchServiceImpl(
unitConverters = if (filters.tools) it.unitConverters else null,
websites = if (filters.websites) it.websites else null,
wikipedia = if (filters.articles) it.wikipedia else null,
locations = if (filters.places) it.locations else null,
)
}
?: SearchResults())