Restructure contacts search for plugins
This commit is contained in:
parent
04dd12ee96
commit
cab93f87aa
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user