From cab93f87aa13c0e797689e895cd3684c66ffac8e Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:18:22 +0200 Subject: [PATCH] Restructure contacts search for plugins --- .../launcher2/ui/launcher/search/SearchVM.kt | 4 +- .../launcher/search/contacts/ContactItem.kt | 6 +- .../contacts/ContactsSettingsScreenVM.kt | 4 +- .../settings/plugins/PluginSettingsScreen.kt | 42 +--- .../settings/search/SearchSettingsScreenVM.kt | 4 +- .../java/de/mm20/launcher2/search/Contact.kt | 5 +- .../preferences/LauncherDataStore.kt | 2 + .../preferences/LauncherSettingsData.kt | 2 + .../preferences/migrations/Migration4.kt | 23 ++ .../search/ContactSearchSettings.kt | 17 +- .../de/mm20/launcher2/plugin/PluginType.kt | 1 + .../plugin/contracts/ContactPluginContract.kt | 4 +- .../launcher2/search/contact/ContactInfo.kt | 2 +- .../launcher2/contacts/ContactRepository.kt | 237 ++---------------- .../contacts/ContactSerialization.kt | 9 +- .../java/de/mm20/launcher2/contacts/Module.kt | 3 +- .../{ => providers}/AndroidContact.kt | 7 +- .../providers/AndroidContactProvider.kt | 223 ++++++++++++++++ .../contacts/providers/ContactProvider.kt | 7 + .../de/mm20/launcher2/sdk/contacts/Contact.kt | 43 ++++ .../launcher2/sdk/contacts/ContactProvider.kt | 49 ++++ .../de/mm20/launcher2/search/SearchService.kt | 1 + 22 files changed, 417 insertions(+), 278 deletions(-) create mode 100644 core/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration4.kt rename data/contacts/src/main/java/de/mm20/launcher2/contacts/{ => providers}/AndroidContact.kt (91%) create mode 100644 data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt create mode 100644 data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index 69c6fb95..42b7dd44 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -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( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt index 43d05374..4b43ae8f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -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 diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt index 8e928666..5c438c03 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt @@ -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) } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt index 89c2cb9d..ab7470fa 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/plugins/PluginSettingsScreen.kt @@ -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, - ) - } - } - } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt index a3f4b265..c53f42c8 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreenVM.kt @@ -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) diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt index 339e9f72..2f0b3a4b 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt @@ -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 val emailAddresses: List val postalAddresses: List - val contactChannels: List + val customActions: List val summary: String get() { diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherDataStore.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherDataStore.kt index e7b80767..5f39e515 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherDataStore.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherDataStore.kt @@ -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(), ), ) { diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index c8c9a933..26b14aa7 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -52,7 +52,9 @@ data class LauncherSettingsData internal constructor( val fileSearchProviders: Set = setOf("local"), + @Deprecated("Use contactSearchProviders `local` instead") val contactSearchEnabled: Boolean = true, + val contactSearchProviders: Set = setOf("local"), val contactSearchCallOnTap: Boolean = false, @Deprecated("Use calendarSearchProviders `local` instead") diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration4.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration4.kt new file mode 100644 index 00000000..4be973cb --- /dev/null +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration4.kt @@ -0,0 +1,23 @@ +package de.mm20.launcher2.preferences.migrations + +import androidx.datastore.core.DataMigration +import de.mm20.launcher2.preferences.LauncherSettingsData + +class Migration4 : DataMigration { + 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 + } + +} \ No newline at end of file diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt index 8eda60e7..7fcd99f7 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt @@ -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 - get() = dataStore.data.map { it.contactSearchEnabled }.distinctUntilChanged() - fun setEnabled(enabled: Boolean) { - dataStore.update { it.copy(contactSearchEnabled = enabled) } + val enabledProviders: Flow> + 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 diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt index 153596d3..f358dd3c 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt @@ -5,4 +5,5 @@ enum class PluginType { Weather, LocationSearch, Calendar, + ContactSearch, } \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt index 983de362..8fab2c8f 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/ContactPluginContract.kt @@ -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>("phone_numbers") val EmailAddresses = column>("email_addresses") val PostalAddresses = column>("postal_addresses") - val ContactChannels = column>("contact_channels") + val CustomActions = column>("custom_actions") val PhotoUri = column("photo_uri") } diff --git a/core/shared/src/main/java/de/mm20/launcher2/search/contact/ContactInfo.kt b/core/shared/src/main/java/de/mm20/launcher2/search/contact/ContactInfo.kt index 1a93a26a..a296fd26 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/search/contact/ContactInfo.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/search/contact/ContactInfo.kt @@ -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. diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt index 29c1eaf5..32ba58a6 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt @@ -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 { - fun get(id: Long): Flow = 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() - 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): 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() - val emailAddresses = mutableListOf() - val postalAddresses = mutableListOf() - val contactChannels = mutableListOf() - - 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> { + override fun search(query: String, allowNetwork: Boolean): Flow> { 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 { - 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>() - while (cursor.moveToNext()) { - contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1)) + supervisorScope { + val result = MutableStateFlow(listOf()) + + for (provider in providers) { + launch { + val r = provider.search( + query, + allowNetwork = allowNetwork, + ) + result.update { it + r } + } + } + emitAll(result) } - cursor.close() - val results = mutableListOf() - for ((id, rawIds) in contactMap) { - getWithRawIds(id, rawIds)?.let { results.add(it) } - if (results.size > 15) break - } - results } - return results.toImmutableList() } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt index 570aed9c..591e04f9 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactSerialization.kt @@ -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) } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt index 7d68328d..3776a916 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/Module.kt @@ -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>(named()) { get() } - factory(named(AndroidContact.Domain)) { ContactDeserializer(get(), get()) } + factory(named(AndroidContact.Domain)) { ContactDeserializer(androidContext(), get()) } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt similarity index 91% rename from data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt rename to data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt index c7643254..3ecb388e 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContact.kt @@ -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, override val emailAddresses: List, override val postalAddresses: List, - override val contactChannels: List, internal val lookupKey: String, + override val customActions: List, internal val lookupKey: String, override val labelOverride: String? = null, ) : Contact { diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt new file mode 100644 index 00000000..7c961343 --- /dev/null +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/AndroidContactProvider.kt @@ -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 { + 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>() + while (cursor.moveToNext()) { + contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1)) + } + cursor.close() + val results = mutableListOf() + 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): 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() + val emailAddresses = mutableListOf() + val postalAddresses = mutableListOf() + val customActions = mutableListOf() + + 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() + while (rawContactsCursor.moveToNext()) { + rawContacts.add(rawContactsCursor.getLong(0)) + } + rawContactsCursor.close() + if (rawContacts.isEmpty()) { + return@withContext null + } + return@withContext getWithRawIds(id, rawContacts) + } +} \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt new file mode 100644 index 00000000..03ce9006 --- /dev/null +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/providers/ContactProvider.kt @@ -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 +} \ No newline at end of file diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt new file mode 100644 index 00000000..5cbcf497 --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/Contact.kt @@ -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 = emptyList(), + /** + * List of email addresses for this contact. + */ + val emailAddresses: List = emptyList(), + /** + * List of postal addresses for this contact. + */ + val postalAddresses: List = emptyList(), + /** + * List of additional actions to contact this person. + * For example, call on WhatsApp, send a message on Telegram, etc. + */ + val customActions: List = 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, +) diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt new file mode 100644 index 00000000..7d04fcbf --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/contacts/ContactProvider.kt @@ -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(config) { + + override fun getQuery(uri: Uri): String? { + return uri.getQueryParameter(SearchPluginContract.Params.Query) + } + + override fun List.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 + } +} \ No newline at end of file diff --git a/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt b/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt index 640576ea..228074a3 100644 --- a/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt +++ b/services/search/src/main/java/de/mm20/launcher2/search/SearchService.kt @@ -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())