ContactItem improvements (#1324)

* ContactRepository: try to deduplicate phoneNumbers in a smart way

* AndroidManifest: use CALL_PHONE permission to allow for making phone calls

* Implement CallOnTap with contact results

- contact activities that require permissions we do not have are not
  listed
- add CallOnTap setting for phone number results on search behind
  contacts settings

* utilize PhoneNumberUtils

* navroute settings/search/contacts

* queryIntentActivities -> resolveActivity

* localization

* Code formatting

* Wrap contact search settings in preference category

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph 2025-03-29 17:18:30 +01:00 committed by GitHub
parent 21894d8df2
commit bceae1aa58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 191 additions and 10 deletions

View File

@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.SET_ALARM" /> <uses-permission android:name="android.permission.SET_ALARM" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" /> <uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" /> <uses-permission android:name="android.permission.GET_ACCOUNTS" />
@ -23,6 +27,7 @@
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />

View File

@ -16,6 +16,7 @@ import de.mm20.launcher2.notifications.Notification
import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.notifications.NotificationRepository
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.LocationSearchSettings import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Application
@ -53,6 +54,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val appShortcutRepository: AppShortcutRepository by inject() private val appShortcutRepository: AppShortcutRepository by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val locationSearchSettings: LocationSearchSettings by inject() private val locationSearchSettings: LocationSearchSettings by inject()
private val contactSearchSettings: ContactSearchSettings by inject()
val isUpToDate = MutableStateFlow(true) val isUpToDate = MutableStateFlow(true)
@ -247,6 +249,9 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
.map { it ?: LocationSearchSettings.DefaultTileServerUrl } .map { it ?: LocationSearchSettings.DefaultTileServerUrl }
.stateIn(viewModelScope, SharingStarted.Lazily, "") .stateIn(viewModelScope, SharingStarted.Lazily, "")
val callOnTap = contactSearchSettings.callOnTap
.stateIn(viewModelScope, SharingStarted.Lazily, false)
fun reportUsage(searchable: SavableSearchable) { fun reportUsage(searchable: SavableSearchable) {
favoritesService.reportLaunch(searchable) favoritesService.reportLaunch(searchable)
} }

View File

@ -83,6 +83,8 @@ import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import androidx.core.net.toUri
import de.mm20.launcher2.ktx.checkPermission
@Composable @Composable
fun ContactItem( fun ContactItem(
@ -101,6 +103,7 @@ fun ContactItem(
} }
val icon by viewModel.icon.collectAsStateWithLifecycle() val icon by viewModel.icon.collectAsStateWithLifecycle()
val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(false)
val badge by viewModel.badge.collectAsState(null) val badge by viewModel.badge.collectAsState(null)
SharedTransitionLayout { SharedTransitionLayout {
@ -183,9 +186,12 @@ fun ContactItem(
onContact = { onContact = {
viewModel.reportUsage(contact) viewModel.reportUsage(contact)
context.tryStartActivity( context.tryStartActivity(
Intent(Intent.ACTION_DIAL).apply { Intent(
data = Uri.parse("tel:${it.number}") if (callOnTap)
} Intent.ACTION_CALL
else
Intent.ACTION_DIAL
).setData("tel:${it.number}".toUri())
) )
}, },
copyText = { it.number }, copyText = { it.number },
@ -300,11 +306,22 @@ fun ContactItem(
app.key app.key
} }
} }
val itemsWithPermission = remember(app) {
app.value.filter {
// exclude activities we have no permission for
val resolvedActivityInfo = context.packageManager.resolveActivity(
Intent(Intent.ACTION_VIEW).setDataAndType(it.uri, it.mimeType),
0
)?.activityInfo ?: return@filter false
resolvedActivityInfo.permission == null || context.checkPermission(resolvedActivityInfo.permission)
}
}
ContactInfo( ContactInfo(
icon = Icons.AutoMirrored.Rounded.OpenInNew, icon = Icons.AutoMirrored.Rounded.OpenInNew,
customIcon = appIcon, customIcon = appIcon,
label = label, label = label,
items = app.value, items = itemsWithPermission,
itemLabel = { it.label }, itemLabel = { it.label },
expanded = expandedSection == 3 + i, expanded = expandedSection == 3 + i,
modifier = Modifier modifier = Modifier

View File

@ -43,6 +43,7 @@ import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen
import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemeSettingsScreen import de.mm20.launcher2.ui.settings.colorscheme.ThemeSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemesSettingsScreen import de.mm20.launcher2.ui.settings.colorscheme.ThemesSettingsScreen
import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsScreen
import de.mm20.launcher2.ui.settings.crashreporter.CrashReportScreen import de.mm20.launcher2.ui.settings.crashreporter.CrashReportScreen
import de.mm20.launcher2.ui.settings.crashreporter.CrashReporterScreen import de.mm20.launcher2.ui.settings.crashreporter.CrashReporterScreen
import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen
@ -227,6 +228,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/favorites") { composable("settings/favorites") {
FavoritesSettingsScreen() FavoritesSettingsScreen()
} }
composable("settings/search/contacts") {
ContactsSettingsScreen()
}
composable("settings/integrations") { composable("settings/integrations") {
IntegrationsSettingsScreen() IntegrationsSettingsScreen()
} }

View File

@ -0,0 +1,58 @@
package de.mm20.launcher2.ui.settings.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Call
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun ContactsSettingsScreen() {
val viewModel: ContactsSettingsScreenVM = viewModel()
val context = LocalContext.current
val hasCallPermission by viewModel.hasCallPermission.collectAsStateWithLifecycle(null)
val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(null)
PreferenceScreen(
title = stringResource(R.string.preference_search_contacts)
) {
item {
PreferenceCategory {
AnimatedVisibility(hasCallPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_call_contacts_settings),
onClick = {
viewModel.requestCallPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_contacts_call_on_tap),
summary = stringResource(R.string.preference_contacts_call_on_tap_summary),
icon = Icons.Rounded.Call,
value = callOnTap == true && hasCallPermission == true,
onValueChanged = {
viewModel.setCallOnTap(it)
},
enabled = hasCallPermission == true
)
}
}
}
}

View File

@ -0,0 +1,30 @@
package de.mm20.launcher2.ui.settings.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: ContactSearchSettings by inject()
private val permissionsManager: PermissionsManager by inject()
val hasCallPermission = permissionsManager.hasPermission(PermissionGroup.Call)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun requestCallPermission(activity: AppCompatActivity) =
permissionsManager.requestPermission(activity, PermissionGroup.Call)
val callOnTap = settings.callOnTap
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setCallOnTap(callOnTap: Boolean) =
settings.setCallOnTap(callOnTap)
}

View File

@ -119,14 +119,17 @@ fun SearchSettingsScreen() {
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
} }
SwitchPreference( PreferenceWithSwitch(
title = stringResource(R.string.preference_search_contacts), title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary), summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person, icon = Icons.Rounded.Person,
value = contacts == true && hasContactsPermission == true, switchValue = contacts == true && hasContactsPermission == true,
onValueChanged = { onSwitchChanged = {
viewModel.setContacts(it) viewModel.setContacts(it)
}, },
onClick = {
navController?.navigate("settings/search/contacts")
},
enabled = hasContactsPermission == true enabled = hasContactsPermission == true
) )

View File

@ -818,4 +818,6 @@
<item quantity="one">in einer Minute</item> <item quantity="one">in einer Minute</item>
<item quantity="other">in %1$d Minuten</item> <item quantity="other">in %1$d Minuten</item>
</plurals> </plurals>
<string name="preference_contacts_call_on_tap">Zum Anruf Tippen</string>
<string name="preference_contacts_call_on_tap_summary">Beim Tippen auf eine Telefonnummer diese direkt Anrufen</string>
</resources> </resources>

View File

@ -390,6 +390,8 @@
<string name="missing_permission_music_widget">Notification access is required to control media playback</string> <string name="missing_permission_music_widget">Notification access is required to control media playback</string>
<!-- Missing contact permission in search settings screen --> <!-- Missing contact permission in search settings screen -->
<string name="missing_permission_contact_search_settings">Contact permission is required to search contacts</string> <string name="missing_permission_contact_search_settings">Contact permission is required to search contacts</string>
<!-- Missing call permission in contacts settings screen -->
<string name="missing_permission_call_contacts_settings">Call permission is required to start calls</string>
<!-- Missing calendar permission in search settings screen --> <!-- Missing calendar permission in search settings screen -->
<string name="missing_permission_calendar_search_settings">Calendar permission is required to search calendar</string> <string name="missing_permission_calendar_search_settings">Calendar permission is required to search calendar</string>
<!-- Missing calendar permission in calendar widget settings screen --> <!-- Missing calendar permission in calendar widget settings screen -->
@ -668,6 +670,8 @@
<string name="preference_category_accounts">Accounts</string> <string name="preference_category_accounts">Accounts</string>
<string name="preference_weather_integration">Weather</string> <string name="preference_weather_integration">Weather</string>
<string name="preference_media_integration">Media control</string> <string name="preference_media_integration">Media control</string>
<string name="preference_contacts_call_on_tap">Call on tap</string>
<string name="preference_contacts_call_on_tap_summary">Directly start calls when tapping phone numbers</string>
<!-- Used in an info banner if a specific feature requires a Nextcloud account --> <!-- Used in an info banner if a specific feature requires a Nextcloud account -->
<string name="no_account_nextcloud">You haven\'t connected a Nextcloud account yet</string> <string name="no_account_nextcloud">You haven\'t connected a Nextcloud account yet</string>
<!-- Used in an info banner if a specific feature requires an Owncloud account --> <!-- Used in an info banner if a specific feature requires an Owncloud account -->

View File

@ -15,3 +15,15 @@ fun <T> List<T>.randomElementOrNull(): T? {
fun <T> List<T>?.ifNullOrEmpty(block: () -> List<T>): List<T> { fun <T> List<T>?.ifNullOrEmpty(block: () -> List<T>): List<T> {
return if (this.isNullOrEmpty()) block() else this return if (this.isNullOrEmpty()) block() else this
} }
fun <T> List<T>.distinctByEquality(equalityPredicate: (T, T) -> Boolean): List<T> {
if (size < 2) return this
val ret = mutableListOf<T>()
for (item in this) {
if (ret.none { equalityPredicate(it, item) }) ret.add(item)
}
return ret
}

View File

@ -65,6 +65,7 @@ enum class PermissionGroup {
AppShortcuts, AppShortcuts,
Accessibility, Accessibility,
ManageProfiles, ManageProfiles,
Call,
} }
internal class PermissionsManagerImpl( internal class PermissionsManagerImpl(
@ -93,6 +94,9 @@ internal class PermissionsManagerImpl(
private val manageProfilesPermissionState = MutableStateFlow( private val manageProfilesPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.ManageProfiles) checkPermissionOnce(PermissionGroup.ManageProfiles)
) )
private val callPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.Call)
)
override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) { override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) {
when (permissionGroup) { when (permissionGroup) {
@ -167,6 +171,14 @@ internal class PermissionsManagerImpl(
CrashReporter.logException(e) CrashReporter.logException(e)
} }
} }
PermissionGroup.Call -> {
ActivityCompat.requestPermissions(
context,
callPermissions,
permissionGroup.ordinal
)
}
} }
} }
@ -209,6 +221,10 @@ internal class PermissionsManagerImpl(
PermissionGroup.Accessibility -> { PermissionGroup.Accessibility -> {
accessibilityPermissionState.value accessibilityPermissionState.value
} }
PermissionGroup.Call -> {
callPermissions.all { context.checkPermission(it) }
}
} }
} }
@ -222,6 +238,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.AppShortcuts -> appShortcutsPermissionState PermissionGroup.AppShortcuts -> appShortcutsPermissionState
PermissionGroup.Accessibility -> accessibilityPermissionState PermissionGroup.Accessibility -> accessibilityPermissionState
PermissionGroup.ManageProfiles -> manageProfilesPermissionState PermissionGroup.ManageProfiles -> manageProfilesPermissionState
PermissionGroup.Call -> callPermissionState
} }
} }
@ -241,6 +258,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted
PermissionGroup.ManageProfiles -> manageProfilesPermissionState.value = granted PermissionGroup.ManageProfiles -> manageProfilesPermissionState.value = granted
PermissionGroup.Call -> callPermissionState.value = granted
} }
} }
@ -269,5 +287,6 @@ internal class PermissionsManagerImpl(
Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE Manifest.permission.WRITE_EXTERNAL_STORAGE
) )
private val callPermissions = arrayOf(Manifest.permission.CALL_PHONE)
} }
} }

View File

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

View File

@ -12,4 +12,11 @@ class ContactSearchSettings internal constructor(private val dataStore: Launcher
fun setEnabled(enabled: Boolean) { fun setEnabled(enabled: Boolean) {
dataStore.update { it.copy(contactSearchEnabled = enabled) } dataStore.update { it.copy(contactSearchEnabled = enabled) }
} }
val callOnTap: Flow<Boolean>
get() = dataStore.data.map { it.contactSearchCallOnTap }.distinctUntilChanged()
fun setCallOnTap(callOnTap: Boolean) {
dataStore.update { it.copy(contactSearchCallOnTap = callOnTap) }
}
} }

View File

@ -2,9 +2,12 @@ package de.mm20.launcher2.contacts
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.os.Build
import android.provider.ContactsContract import android.provider.ContactsContract
import android.telephony.PhoneNumberUtils
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull 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
@ -136,11 +139,10 @@ internal class ContactRepository(
} }
else -> { else -> {
val mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue
contactApps += ContactApp( contactApps += ContactApp(
label = dataCursor.getStringOrNull(data3Column) ?: continue, label = dataCursor.getStringOrNull(data3Column) ?: continue,
packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue, packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue,
mimeType = mimeType, mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue,
uri = ContentUris.withAppendedId( uri = ContentUris.withAppendedId(
ContactsContract.Data.CONTENT_URI, ContactsContract.Data.CONTENT_URI,
dataCursor.getLongOrNull(idColumn) ?: continue dataCursor.getLongOrNull(idColumn) ?: continue
@ -164,12 +166,24 @@ internal class ContactRepository(
} }
lookupKeyCursor.close() lookupKeyCursor.close()
val mainLocaleISO3 = context.resources.configuration.locales[0].isO3Country
return@withContext AndroidContact( return@withContext AndroidContact(
id = id, id = id,
firstName = firstName, firstName = firstName,
lastName = lastName, lastName = lastName,
displayName = displayName, displayName = displayName,
phoneNumbers = phoneNumbers.distinct(), phoneNumbers = phoneNumbers.sortedByDescending {
it.number.count { !PhoneNumberUtils.isReallyDialable(it) }
}.distinctByEquality { a, b ->
if (Build.VERSION.SDK_INT < 31) {
PhoneNumberUtils.compare(context, a.number, b.number)
} else {
PhoneNumberUtils.areSamePhoneNumber(a.number, b.number, mainLocaleISO3)
}
}.map {
it.copy(number = PhoneNumberUtils.formatNumber(it.number, mainLocaleISO3))
},
emailAddresses = emailAddresses.distinct(), emailAddresses = emailAddresses.distinct(),
postalAddresses = postalAddresses.distinct(), postalAddresses = postalAddresses.distinct(),
contactApps = contactApps.distinct(), contactApps = contactApps.distinct(),