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(
|
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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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(),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
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>
|
||||||
|
|||||||
@ -5,4 +5,5 @@ enum class PluginType {
|
|||||||
Weather,
|
Weather,
|
||||||
LocationSearch,
|
LocationSearch,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
ContactSearch,
|
||||||
}
|
}
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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()) }
|
||||||
}
|
}
|
||||||
@ -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 {
|
||||||
|
|
||||||
@ -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,
|
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())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user