add setting to exclude apps from shortcuts search

This commit is contained in:
MM20
2026-08-23 17:54:25 +02:00
parent 61c43650ba
commit 17f8906f26
9 changed files with 267 additions and 46 deletions

View File

@@ -52,6 +52,8 @@ import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsRoute
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsRoute
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsScreen
import de.mm20.launcher2.ui.settings.appshortcuts.AppShortcutsSettingsRoute
import de.mm20.launcher2.ui.settings.appshortcuts.AppShortcutsSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsRoute
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsRoute
@@ -261,6 +263,9 @@ class SettingsActivity : BaseActivity() {
entry<FavoritesSettingsRoute> {
FavoritesSettingsScreen()
}
entry<AppShortcutsSettingsRoute> {
AppShortcutsSettingsScreen()
}
entry<ContactsSettingsRoute> {
ContactsSettingsScreen()
}

View File

@@ -0,0 +1,66 @@
package de.mm20.launcher2.ui.settings.appshortcuts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.BadgeService
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ShortcutSearchSettings
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class AppShortcutSettingsScreenVM : ViewModel(), KoinComponent {
private val appRepository by inject<AppRepository>()
private val settings by inject<ShortcutSearchSettings>()
private val permissionsManager by inject<PermissionsManager>()
private val iconService by inject<IconService>()
private val badgeService by inject<BadgeService>()
val blocklist =
settings.blocklist.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet())
val apps = appRepository.findMany().map { it.sorted() }.flowOn(Dispatchers.Default)
val isShortcutSearchEnabled = settings.enabled
val hasAppShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setShortcutSearchEnabled(enabled: Boolean) {
settings.setEnabled(enabled)
}
fun setAppBlocklisted(appId: String, blocked: Boolean) {
val blocklist = blocklist.value.toMutableSet()
if (blocked) {
blocklist.add(appId)
} else {
blocklist.remove(appId)
}
settings.setBlocklist(blocklist)
}
fun requestAppShortcutsPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts)
}
fun getIcon(app: SavableSearchable, size: Int): Flow<LauncherIcon?> {
return iconService.getIcon(app, size)
}
fun getBadge(app: SavableSearchable): Flow<Badge?> {
return badgeService.getBadge(app)
}
}

View File

@@ -0,0 +1,121 @@
package de.mm20.launcher2.ui.settings.appshortcuts
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 androidx.navigation3.runtime.NavKey
import de.mm20.launcher2.ktx.getSerialNumber
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.GuardedPreference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.ktx.toPixels
data object AppShortcutsSettingsRoute : NavKey
@Composable
fun AppShortcutsSettingsScreen() {
val viewModel: AppShortcutSettingsScreenVM = viewModel()
val searchEnabled by viewModel.isShortcutSearchEnabled.collectAsStateWithLifecycle(null)
val hasPermission by viewModel.hasAppShortcutPermission.collectAsStateWithLifecycle(
null
)
val apps by viewModel.apps.collectAsStateWithLifecycle(emptyList())
val blocklist by viewModel.blocklist.collectAsStateWithLifecycle()
val activity = LocalActivity.current
val context = LocalContext.current
val xsShape = MaterialTheme.shapes.extraSmall
val mdShape = MaterialTheme.shapes.medium
PreferenceScreen(
title = { Text(stringResource(R.string.preference_search_appshortcuts)) },
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
item {
Box(modifier = Modifier.padding(bottom = 10.dp)) {
PreferenceCategory {
GuardedPreference(
locked = hasPermission == false,
onUnlock = {
viewModel.requestAppShortcutsPermission(activity as AppCompatActivity)
},
description = stringResource(
R.string.missing_permission_appshortcuts_search_settings,
stringResource(R.string.app_name),
),
) {
SwitchPreference(
title = stringResource(R.string.preference_search_appshortcuts),
summary = stringResource(R.string.preference_search_appshortcuts_summary),
icon = R.drawable.mobile_arrow_up_right_24px,
value = searchEnabled == true && hasPermission == true,
onValueChanged = {
viewModel.setShortcutSearchEnabled(it)
},
enabled = hasPermission == true,
)
}
}
}
}
itemsIndexed(apps) { index, app ->
val key = "${app.componentName.packageName}:${app.user.getSerialNumber(context)}"
Box(
modifier = Modifier.clip(
when {
apps.size == 1 -> mdShape
index == 0 -> mdShape.copy(
bottomEnd = xsShape.bottomEnd,
bottomStart = xsShape.bottomStart
)
index == apps.size - 1 -> mdShape.copy(
topEnd = xsShape.topEnd,
topStart = xsShape.topStart
)
else -> xsShape
}
)
) {
CheckboxPreference(
icon = {
val size = 32.dp
val icon by viewModel.getIcon(app, size.toPixels().toInt())
.collectAsStateWithLifecycle(null)
val badge by viewModel.getBadge(app)
.collectAsStateWithLifecycle(null)
ShapedLauncherIcon(size = size, icon = { icon }, badge = { badge })
},
title = app.label,
value = !blocklist.contains(key),
onValueChanged = {
viewModel.setAppBlocklisted(key, !it)
},
)
}
}
}
}

View File

@@ -34,6 +34,7 @@ import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.launcher.search.filters.SearchFilters
import de.mm20.launcher2.ui.locals.LocalBackStack
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsRoute
import de.mm20.launcher2.ui.settings.appshortcuts.AppShortcutsSettingsRoute
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarProviderSettingsRoute
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsRoute
import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsRoute
@@ -208,15 +209,18 @@ fun SearchSettingsScreen() {
stringResource(R.string.app_name),
),
) {
SwitchPreference(
PreferenceWithSwitch(
title = stringResource(R.string.preference_search_appshortcuts),
summary = stringResource(R.string.preference_search_appshortcuts_summary),
icon = R.drawable.mobile_arrow_up_right_24px,
value = appShortcuts == true && hasAppShortcutsPermission == true,
onValueChanged = {
switchValue = appShortcuts == true && hasAppShortcutsPermission == true,
onSwitchChanged = {
viewModel.setAppShortcuts(it)
},
enabled = hasAppShortcutsPermission == true
enabled = hasAppShortcutsPermission == true,
onClick = {
backStack += AppShortcutsSettingsRoute
}
)
}

View File

@@ -5,7 +5,11 @@ import android.os.Process
import android.os.UserHandle
import android.os.UserManager
private val cache = mutableMapOf<Int, Long>()
fun UserHandle.getSerialNumber(context: Context): Long {
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
return userManager.getSerialNumberForUser(this)
return cache.getOrPut(hashCode()) {
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
userManager.getSerialNumberForUser(this)
}
}

View File

@@ -87,6 +87,7 @@ data class LauncherSettingsData internal constructor(
val calendarSearchExcludedCalendars: Set<String> = setOf(),
val shortcutSearchEnabled: Boolean = true,
val shortcutSearchBlocklist: Set<String> = setOf(),
val calculatorEnabled: Boolean = true,

View File

@@ -10,9 +10,22 @@ class ShortcutSearchSettings internal constructor(
val enabled
get() = dataStore.data.map { it.shortcutSearchEnabled }.distinctUntilChanged()
/**
* Set of blocked packages that should not be shown in the search results.
* Format: packageName:userId
*/
val blocklist
get() = dataStore.data.map { it.shortcutSearchBlocklist }.distinctUntilChanged()
fun setEnabled(enabled: Boolean) {
dataStore.update {
it.copy(shortcutSearchEnabled = enabled)
}
}
fun setBlocklist(blocklist: Set<String>) {
dataStore.update {
it.copy(shortcutSearchBlocklist = blocklist)
}
}
}

View File

@@ -9,6 +9,7 @@ import android.os.Looper
import android.os.Process
import android.os.UserHandle
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.getSerialNumber
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ShortcutSearchSettings
@@ -23,9 +24,9 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
@@ -86,17 +87,18 @@ internal class AppShortcutRepositoryImpl(
emptyList()
}
val appShortcuts = mutableListOf<LauncherShortcut>()
appShortcuts.addAll(shortcuts
?.let {
if (it.size > limit) it.subList(0, limit)
else it
}
?.map {
LauncherShortcut(
context,
it,
)
} ?: emptyList()
appShortcuts.addAll(
shortcuts
?.let {
if (it.size > limit) it.subList(0, limit)
else it
}
?.map {
LauncherShortcut(
context,
it,
)
} ?: emptyList()
)
appShortcuts
}
@@ -153,12 +155,15 @@ internal class AppShortcutRepositoryImpl(
val callback = object : LauncherApps.Callback() {
override fun onPackageRemoved(packageName: String?, user: UserHandle?) {
trySend(Unit)
}
override fun onPackageAdded(packageName: String?, user: UserHandle?) {
trySend(Unit)
}
override fun onPackageChanged(packageName: String?, user: UserHandle?) {
trySend(Unit)
}
override fun onPackagesAvailable(
@@ -166,6 +171,7 @@ internal class AppShortcutRepositoryImpl(
user: UserHandle?,
replacing: Boolean
) {
trySend(Unit)
}
override fun onPackagesUnavailable(
@@ -173,6 +179,7 @@ internal class AppShortcutRepositoryImpl(
user: UserHandle?,
replacing: Boolean
) {
trySend(Unit)
}
override fun onShortcutsChanged(
@@ -196,40 +203,41 @@ internal class AppShortcutRepositoryImpl(
private val rawShortcuts: Flow<List<NormalizedShortcut>> = combine(
listOf(
settings.enabled,
settings.blocklist,
permissionsManager.hasPermission(PermissionGroup.AppShortcuts),
shortcutChangeEmitter
)
) { it }
.map { (enabled, perm, _) ->
enabled as Boolean
perm as Boolean
) { (enabled, blocklist, perm, _) ->
enabled as Boolean
perm as Boolean
blocklist as Set<String>
if (!enabled || !perm) return@map emptyList()
if (!enabled || !perm) return@combine emptyList()
val launcherApps =
context.getSystemService<LauncherApps>() ?: return@map emptyList()
val launcherApps =
context.getSystemService<LauncherApps>() ?: return@combine emptyList()
val shortcutQuery = LauncherApps.ShortcutQuery()
shortcutQuery.setQueryFlags(
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
)
val result = launcherApps.getShortcuts(shortcutQuery, Process.myUserHandle()) ?: emptyList()
val normalized = result.map {
NormalizedShortcut(
info = it,
normalizedLabels = listOfNotNull(
it.longLabel?.toString()?.let { l -> stringNormalizer.normalize(l) },
it.shortLabel?.toString()?.let { l -> stringNormalizer.normalize(l) },
)
val shortcutQuery = LauncherApps.ShortcutQuery()
shortcutQuery.setQueryFlags(
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
)
val result = launcherApps.getShortcuts(shortcutQuery, Process.myUserHandle()) ?: emptyList()
val normalized = result.mapNotNull {
if ("${it.`package`}:${it.userHandle.getSerialNumber(context)}" in blocklist) return@mapNotNull null
NormalizedShortcut(
info = it,
normalizedLabels = listOfNotNull(
it.longLabel?.toString()?.let { l -> stringNormalizer.normalize(l) },
it.shortLabel?.toString()?.let { l -> stringNormalizer.normalize(l) },
)
}
normalized
)
}
.flowOn(Dispatchers.Default)
normalized
}
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
override suspend fun getShortcutsConfigActivities(): List<AppShortcutConfigActivity> {

View File

@@ -17,7 +17,7 @@ kotlinx-coroutines = "1.11.0"
kotlinx-immutable = "0.5.1"
kotlinx-serialization = "1.11.0"
jetbrains-markdown = "0.7.8"
jetbrains-markdown = "0.7.9"
androidx-compose = "1.13.0-alpha01"
androidx-compose-material3 = "1.5.0-alpha26"
@@ -33,7 +33,6 @@ androidx-palette = "1.0.0"
androidx-room = "2.8.4"
androidx-emojipicker = "1.6.0"
accompanist = "0.36.0"
haze = "1.7.2"
coil = "2.7.0"
koin = "4.2.2"