Restructure contacts search for plugins

This commit is contained in:
MM20 2025-04-05 17:18:22 +02:00
parent 04dd12ee96
commit cab93f87aa
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
22 changed files with 417 additions and 278 deletions

View File

@ -347,7 +347,7 @@ class SearchVM : ViewModel(), KoinComponent {
val missingContactsPermission = combine( val missingContactsPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Contacts), permissionsManager.hasPermission(PermissionGroup.Contacts),
contactSearchSettings.enabled contactSearchSettings.isProviderEnabled("local")
) { perm, enabled -> !perm && enabled } ) { perm, enabled -> !perm && enabled }
fun requestContactsPermission(context: AppCompatActivity) { fun requestContactsPermission(context: AppCompatActivity) {
@ -355,7 +355,7 @@ class SearchVM : ViewModel(), KoinComponent {
} }
fun disableContactsSearch() { fun disableContactsSearch() {
contactSearchSettings.setEnabled(false) contactSearchSettings.setProviderEnabled("local", false)
} }
val missingLocationPermission = combine( val missingLocationPermission = combine(

View File

@ -2,7 +2,6 @@ package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import androidx.compose.animation.AnimatedContent 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.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.modifier.scale 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 androidx.core.net.toUri
import de.mm20.launcher2.ktx.checkPermission import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.getApplicationIconOrNull import de.mm20.launcher2.ktx.getApplicationIconOrNull
@ -276,7 +272,7 @@ fun ContactItem(
) )
} }
val apps = remember(contact) { val apps = remember(contact) {
contact.contactChannels.groupBy { it.packageName } contact.customActions.groupBy { it.packageName }
} }
for ((i, app) in apps.entries.withIndex()) { for ((i, app) in apps.entries.withIndex()) {
val packageName = app.key val packageName = app.key

View File

@ -34,10 +34,10 @@ class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.Contacts) permissionsManager.requestPermission(activity, PermissionGroup.Contacts)
} }
val localContacts = settings.enabled val localContacts = settings.isProviderEnabled("local")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setLocalContacts(enabled: Boolean) { fun setLocalContacts(enabled: Boolean) {
settings.setEnabled(enabled) settings.setProviderEnabled("local", enabled)
} }
} }

View File

@ -194,6 +194,7 @@ fun PluginSettingsScreen(pluginId: String) {
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
.padding(bottom = 16.dp)
) { ) {
Column { Column {
Row( Row(
@ -248,47 +249,6 @@ fun PluginSettingsScreen(pluginId: String) {
style = MaterialTheme.typography.bodyMedium, 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,
)
}
}
}
} }
} }

View File

@ -55,11 +55,11 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts) val hasContactsPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val contacts = contactSearchSettings.enabled val contacts = contactSearchSettings.isProviderEnabled("local")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setContacts(contacts: Boolean) { fun setContacts(contacts: Boolean) {
contactSearchSettings.setEnabled(contacts) contactSearchSettings.setProviderEnabled("local", contacts)
} }
val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location) val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)

View File

@ -1,14 +1,13 @@
package de.mm20.launcher2.search package de.mm20.launcher2.search
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.icons.VectorLayer 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.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress import de.mm20.launcher2.search.contact.PostalAddress
@ -18,7 +17,7 @@ interface Contact : SavableSearchable {
val phoneNumbers: List<PhoneNumber> val phoneNumbers: List<PhoneNumber>
val emailAddresses: List<EmailAddress> val emailAddresses: List<EmailAddress>
val postalAddresses: List<PostalAddress> val postalAddresses: List<PostalAddress>
val contactChannels: List<CustomContactChannel> val customActions: List<CustomContactAction>
val summary: String val summary: String
get() { get() {

View File

@ -3,6 +3,7 @@ package de.mm20.launcher2.preferences
import android.content.Context import android.content.Context
import de.mm20.launcher2.preferences.migrations.Migration2 import de.mm20.launcher2.preferences.migrations.Migration2
import de.mm20.launcher2.preferences.migrations.Migration3 import de.mm20.launcher2.preferences.migrations.Migration3
import de.mm20.launcher2.preferences.migrations.Migration4
import de.mm20.launcher2.settings.BaseSettings import de.mm20.launcher2.settings.BaseSettings
internal class LauncherDataStore( internal class LauncherDataStore(
@ -14,6 +15,7 @@ internal class LauncherDataStore(
migrations = listOf( migrations = listOf(
Migration2(), Migration2(),
Migration3(), Migration3(),
Migration4(),
), ),
) { ) {

View File

@ -52,7 +52,9 @@ data class LauncherSettingsData internal constructor(
val fileSearchProviders: Set<String> = setOf("local"), val fileSearchProviders: Set<String> = setOf("local"),
@Deprecated("Use contactSearchProviders `local` instead")
val contactSearchEnabled: Boolean = true, val contactSearchEnabled: Boolean = true,
val contactSearchProviders: Set<String> = setOf("local"),
val contactSearchCallOnTap: Boolean = false, val contactSearchCallOnTap: Boolean = false,
@Deprecated("Use calendarSearchProviders `local` instead") @Deprecated("Use calendarSearchProviders `local` instead")

View File

@ -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
}
}

View File

@ -6,11 +6,20 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class ContactSearchSettings internal constructor(private val dataStore: LauncherDataStore) { class ContactSearchSettings internal constructor(private val dataStore: LauncherDataStore) {
val enabled: Flow<Boolean>
get() = dataStore.data.map { it.contactSearchEnabled }.distinctUntilChanged()
fun setEnabled(enabled: Boolean) { val enabledProviders: Flow<Set<String>>
dataStore.update { it.copy(contactSearchEnabled = enabled) } 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> val callOnTap: Flow<Boolean>

View File

@ -5,4 +5,5 @@ enum class PluginType {
Weather, Weather,
LocationSearch, LocationSearch,
Calendar, Calendar,
ContactSearch,
} }

View File

@ -1,6 +1,6 @@
package de.mm20.launcher2.plugin.contracts 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.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress import de.mm20.launcher2.search.contact.PostalAddress
@ -21,7 +21,7 @@ abstract class ContactPluginContract {
val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers") val PhoneNumbers = column<List<PhoneNumber>>("phone_numbers")
val EmailAddresses = column<List<EmailAddress>>("email_addresses") val EmailAddresses = column<List<EmailAddress>>("email_addresses")
val PostalAddresses = column<List<PostalAddress>>("postal_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") val PhotoUri = column<String>("photo_uri")
} }

View File

@ -26,7 +26,7 @@ data class PostalAddress(
* Custom contact channel, for example, WhatsApp message, Telegram video call, etc. * Custom contact channel, for example, WhatsApp message, Telegram video call, etc.
*/ */
@Serializable @Serializable
data class CustomContactChannel( data class CustomContactAction(
val label: String, val label: String,
/** /**
* The data URI that is passed to the Intent. * The data URI that is passed to the Intent.

View File

@ -1,32 +1,21 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.os.Build import de.mm20.launcher2.contacts.providers.AndroidContactProvider
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.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableRepository 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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow 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.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
internal class ContactRepository( internal class ContactRepository(
private val context: Context, private val context: Context,
@ -34,167 +23,7 @@ internal class ContactRepository(
private val settings: ContactSearchSettings, private val settings: ContactSearchSettings,
) : SearchableRepository<Contact> { ) : SearchableRepository<Contact> {
fun get(id: Long): Flow<Contact?> = flow { override fun search(query: String, allowNetwork: Boolean): Flow<List<Contact>> {
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>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts) val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
if (query.length < 2) { if (query.length < 2) {
@ -203,40 +32,28 @@ internal class ContactRepository(
} }
} }
return hasPermission.combine(settings.enabled) { perm, en -> perm && en }.map { return hasPermission.combineTransform(settings.enabledProviders) { perm, providerIds ->
if (it) { val providers = providerIds.mapNotNull {
queryContacts(query) when (it) {
} else { "local" -> if (perm) AndroidContactProvider(context) else null
persistentListOf() else -> null
}
} }
}
}
private suspend fun queryContacts(query: String): ImmutableList<Contact> { supervisorScope {
val results = withContext(Dispatchers.IO) { val result = MutableStateFlow(listOf<Contact>())
val proj = arrayOf(
ContactsContract.RawContacts.CONTACT_ID, for (provider in providers) {
ContactsContract.RawContacts._ID launch {
) val r = provider.search(
val sel = query,
"${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 ?" allowNetwork = allowNetwork,
val selArgs = arrayOf("%$query%", "%$query%", "%$query%", "%$query%") )
val cursor = context.contentResolver.query( result.update { it + r }
ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null }
) ?: return@withContext mutableListOf() }
//Maps raw contact ids to contact ids emitAll(result)
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.toImmutableList()
} }
} }

View File

@ -1,5 +1,8 @@
package de.mm20.launcher2.contacts 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.ktx.jsonObjectOf
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
@ -22,7 +25,7 @@ internal class ContactSerializer : SearchableSerializer {
} }
internal class ContactDeserializer( internal class ContactDeserializer(
private val contactRepository: ContactRepository, private val context: Context,
private val permissionsManager: PermissionsManager private val permissionsManager: PermissionsManager
) : SearchableDeserializer { ) : SearchableDeserializer {
@ -30,6 +33,8 @@ internal class ContactDeserializer(
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Contacts)) return null if (!permissionsManager.checkPermissionOnce(PermissionGroup.Contacts)) return null
val id = JSONObject(serialized).getLong("id") val id = JSONObject(serialized).getLong("id")
return contactRepository.get(id).first() val androidContactProvider = AndroidContactProvider(context)
return androidContactProvider.get(id)
} }
} }

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts
import de.mm20.launcher2.contacts.providers.AndroidContact
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository import de.mm20.launcher2.search.SearchableRepository
@ -10,5 +11,5 @@ import org.koin.dsl.module
val contactsModule = module { val contactsModule = module {
factory { ContactRepository(androidContext(), get(), get()) } factory { ContactRepository(androidContext(), get(), get()) }
factory<SearchableRepository<Contact>>(named<Contact>()) { get<ContactRepository>() } factory<SearchableRepository<Contact>>(named<Contact>()) { get<ContactRepository>() }
factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(get(), get()) } factory<SearchableDeserializer>(named(AndroidContact.Domain)) { ContactDeserializer(androidContext(), get()) }
} }

View File

@ -1,4 +1,4 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts.providers
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import de.mm20.launcher2.contacts.ContactSerializer
import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.StaticIconLayer 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.ktx.tryStartActivity
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.SearchableSerializer 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.EmailAddress
import de.mm20.launcher2.search.contact.PhoneNumber import de.mm20.launcher2.search.contact.PhoneNumber
import de.mm20.launcher2.search.contact.PostalAddress import de.mm20.launcher2.search.contact.PostalAddress
@ -27,7 +28,7 @@ internal data class AndroidContact(
override val phoneNumbers: List<PhoneNumber>, override val phoneNumbers: List<PhoneNumber>,
override val emailAddresses: List<EmailAddress>, override val emailAddresses: List<EmailAddress>,
override val postalAddresses: List<PostalAddress>, 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, override val labelOverride: String? = null,
) : Contact { ) : Contact {

View File

@ -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)
}
}

View File

@ -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>
}

View File

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

View File

@ -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
}
}

View File

@ -70,6 +70,7 @@ internal class SearchServiceImpl(
unitConverters = if (filters.tools) it.unitConverters else null, unitConverters = if (filters.tools) it.unitConverters else null,
websites = if (filters.websites) it.websites else null, websites = if (filters.websites) it.websites else null,
wikipedia = if (filters.articles) it.wikipedia else null, wikipedia = if (filters.articles) it.wikipedia else null,
locations = if (filters.places) it.locations else null,
) )
} }
?: SearchResults()) ?: SearchResults())