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.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsRoute import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsRoute
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsScreen 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.BackupSettingsRoute
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsRoute import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsRoute
@@ -261,6 +263,9 @@ class SettingsActivity : BaseActivity() {
entry<FavoritesSettingsRoute> { entry<FavoritesSettingsRoute> {
FavoritesSettingsScreen() FavoritesSettingsScreen()
} }
entry<AppShortcutsSettingsRoute> {
AppShortcutsSettingsScreen()
}
entry<ContactsSettingsRoute> { entry<ContactsSettingsRoute> {
ContactsSettingsScreen() 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.launcher.search.filters.SearchFilters
import de.mm20.launcher2.ui.locals.LocalBackStack import de.mm20.launcher2.ui.locals.LocalBackStack
import de.mm20.launcher2.ui.settings.apps.AppSearchSettingsRoute 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.CalendarProviderSettingsRoute
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsRoute import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsRoute
import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsRoute import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsRoute
@@ -208,15 +209,18 @@ fun SearchSettingsScreen() {
stringResource(R.string.app_name), stringResource(R.string.app_name),
), ),
) { ) {
SwitchPreference( PreferenceWithSwitch(
title = stringResource(R.string.preference_search_appshortcuts), title = stringResource(R.string.preference_search_appshortcuts),
summary = stringResource(R.string.preference_search_appshortcuts_summary), summary = stringResource(R.string.preference_search_appshortcuts_summary),
icon = R.drawable.mobile_arrow_up_right_24px, icon = R.drawable.mobile_arrow_up_right_24px,
value = appShortcuts == true && hasAppShortcutsPermission == true, switchValue = appShortcuts == true && hasAppShortcutsPermission == true,
onValueChanged = { onSwitchChanged = {
viewModel.setAppShortcuts(it) 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.UserHandle
import android.os.UserManager import android.os.UserManager
private val cache = mutableMapOf<Int, Long>()
fun UserHandle.getSerialNumber(context: Context): Long { fun UserHandle.getSerialNumber(context: Context): Long {
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager return cache.getOrPut(hashCode()) {
return userManager.getSerialNumberForUser(this) 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 calendarSearchExcludedCalendars: Set<String> = setOf(),
val shortcutSearchEnabled: Boolean = true, val shortcutSearchEnabled: Boolean = true,
val shortcutSearchBlocklist: Set<String> = setOf(),
val calculatorEnabled: Boolean = true, val calculatorEnabled: Boolean = true,

View File

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

View File

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