diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index ec46169e..9a50ce2c 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + @@ -23,6 +27,7 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> + diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt index e88f5a9d..fbb2bef0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -16,6 +16,7 @@ import de.mm20.launcher2.notifications.Notification import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.Application @@ -53,6 +54,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { private val appShortcutRepository: AppShortcutRepository by inject() private val permissionsManager: PermissionsManager by inject() private val locationSearchSettings: LocationSearchSettings by inject() + private val contactSearchSettings: ContactSearchSettings by inject() val isUpToDate = MutableStateFlow(true) @@ -247,6 +249,9 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { .map { it ?: LocationSearchSettings.DefaultTileServerUrl } .stateIn(viewModelScope, SharingStarted.Lazily, "") + val callOnTap = contactSearchSettings.callOnTap + .stateIn(viewModelScope, SharingStarted.Lazily, false) + fun reportUsage(searchable: SavableSearchable) { favoritesService.reportLaunch(searchable) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt index a696d42f..efacd775 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -83,6 +83,8 @@ 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 @Composable fun ContactItem( @@ -101,6 +103,7 @@ fun ContactItem( } val icon by viewModel.icon.collectAsStateWithLifecycle() + val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(false) val badge by viewModel.badge.collectAsState(null) SharedTransitionLayout { @@ -183,9 +186,12 @@ fun ContactItem( onContact = { viewModel.reportUsage(contact) context.tryStartActivity( - Intent(Intent.ACTION_DIAL).apply { - data = Uri.parse("tel:${it.number}") - } + Intent( + if (callOnTap) + Intent.ACTION_CALL + else + Intent.ACTION_DIAL + ).setData("tel:${it.number}".toUri()) ) }, copyText = { it.number }, @@ -300,11 +306,22 @@ fun ContactItem( 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( icon = Icons.AutoMirrored.Rounded.OpenInNew, customIcon = appIcon, label = label, - items = app.value, + items = itemsWithPermission, itemLabel = { it.label }, expanded = expandedSection == 3 + i, modifier = Modifier diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 2afddb4e..e058b85e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -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.colorscheme.ThemeSettingsScreen 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.CrashReporterScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen @@ -227,6 +228,9 @@ class SettingsActivity : BaseActivity() { composable("settings/favorites") { FavoritesSettingsScreen() } + composable("settings/search/contacts") { + ContactsSettingsScreen() + } composable("settings/integrations") { IntegrationsSettingsScreen() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt new file mode 100644 index 00000000..87df1978 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreen.kt @@ -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 + ) + } + } + } + +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt new file mode 100644 index 00000000..e8d84b47 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/contacts/ContactsSettingsScreenVM.kt @@ -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) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index 9285a6ff..903b47a7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -119,14 +119,17 @@ fun SearchSettingsScreen() { modifier = Modifier.padding(16.dp) ) } - SwitchPreference( + PreferenceWithSwitch( title = stringResource(R.string.preference_search_contacts), summary = stringResource(R.string.preference_search_contacts_summary), icon = Icons.Rounded.Person, - value = contacts == true && hasContactsPermission == true, - onValueChanged = { + switchValue = contacts == true && hasContactsPermission == true, + onSwitchChanged = { viewModel.setContacts(it) }, + onClick = { + navController?.navigate("settings/search/contacts") + }, enabled = hasContactsPermission == true ) diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 08319907..64f7bd6d 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -818,4 +818,6 @@ in einer Minute in %1$d Minuten + Zum Anruf Tippen + Beim Tippen auf eine Telefonnummer diese direkt Anrufen \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 5ac31517..ac357c5b 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -390,6 +390,8 @@ Notification access is required to control media playback Contact permission is required to search contacts + + Call permission is required to start calls Calendar permission is required to search calendar @@ -668,6 +670,8 @@ Accounts Weather Media control + Call on tap + Directly start calls when tapping phone numbers You haven\'t connected a Nextcloud account yet diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt index f305ba7e..e39230dc 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/List.kt @@ -15,3 +15,15 @@ fun List.randomElementOrNull(): T? { fun List?.ifNullOrEmpty(block: () -> List): List { return if (this.isNullOrEmpty()) block() else this } + +fun List.distinctByEquality(equalityPredicate: (T, T) -> Boolean): List { + if (size < 2) return this + + val ret = mutableListOf() + + for (item in this) { + if (ret.none { equalityPredicate(it, item) }) ret.add(item) + } + + return ret +} diff --git a/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt b/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt index cf79a5e6..b471ef3c 100644 --- a/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt +++ b/core/permissions/src/main/java/de/mm20/launcher2/permissions/PermissionsManager.kt @@ -65,6 +65,7 @@ enum class PermissionGroup { AppShortcuts, Accessibility, ManageProfiles, + Call, } internal class PermissionsManagerImpl( @@ -93,6 +94,9 @@ internal class PermissionsManagerImpl( private val manageProfilesPermissionState = MutableStateFlow( checkPermissionOnce(PermissionGroup.ManageProfiles) ) + private val callPermissionState = MutableStateFlow( + checkPermissionOnce(PermissionGroup.Call) + ) override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) { when (permissionGroup) { @@ -167,6 +171,14 @@ internal class PermissionsManagerImpl( CrashReporter.logException(e) } } + + PermissionGroup.Call -> { + ActivityCompat.requestPermissions( + context, + callPermissions, + permissionGroup.ordinal + ) + } } } @@ -209,6 +221,10 @@ internal class PermissionsManagerImpl( PermissionGroup.Accessibility -> { accessibilityPermissionState.value } + + PermissionGroup.Call -> { + callPermissions.all { context.checkPermission(it) } + } } } @@ -222,6 +238,7 @@ internal class PermissionsManagerImpl( PermissionGroup.AppShortcuts -> appShortcutsPermissionState PermissionGroup.Accessibility -> accessibilityPermissionState PermissionGroup.ManageProfiles -> manageProfilesPermissionState + PermissionGroup.Call -> callPermissionState } } @@ -241,6 +258,7 @@ internal class PermissionsManagerImpl( PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted PermissionGroup.Accessibility -> accessibilityPermissionState.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.WRITE_EXTERNAL_STORAGE ) + private val callPermissions = arrayOf(Manifest.permission.CALL_PHONE) } } diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index ede1fec0..43c01e92 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -54,6 +54,7 @@ data class LauncherSettingsData internal constructor( val fileSearchProviders: Set = setOf("local"), val contactSearchEnabled: Boolean = true, + val contactSearchCallOnTap: Boolean = false, @Deprecated("Use calendarSearchProviders `local` instead") val calendarSearchEnabled: Boolean = true, diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt index 0565e064..8eda60e7 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/ContactSearchSettings.kt @@ -12,4 +12,11 @@ class ContactSearchSettings internal constructor(private val dataStore: Launcher fun setEnabled(enabled: Boolean) { dataStore.update { it.copy(contactSearchEnabled = enabled) } } + + val callOnTap: Flow + get() = dataStore.data.map { it.contactSearchCallOnTap }.distinctUntilChanged() + + fun setCallOnTap(callOnTap: Boolean) { + dataStore.update { it.copy(contactSearchCallOnTap = callOnTap) } + } } \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt index 76547f34..f8fd95f5 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt @@ -2,9 +2,12 @@ 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.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.search.ContactSearchSettings @@ -136,11 +139,10 @@ internal class ContactRepository( } else -> { - val mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue contactApps += ContactApp( label = dataCursor.getStringOrNull(data3Column) ?: continue, packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue, - mimeType = mimeType, + mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue, uri = ContentUris.withAppendedId( ContactsContract.Data.CONTENT_URI, dataCursor.getLongOrNull(idColumn) ?: continue @@ -164,12 +166,24 @@ internal class ContactRepository( } lookupKeyCursor.close() + val mainLocaleISO3 = context.resources.configuration.locales[0].isO3Country + return@withContext AndroidContact( id = id, firstName = firstName, lastName = lastName, 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(), postalAddresses = postalAddresses.distinct(), contactApps = contactApps.distinct(),