From 785849d395bdfcce27a9cc508cf40d6c683bd8e9 Mon Sep 17 00:00:00 2001 From: Tosko4 Date: Mon, 1 Jun 2026 00:43:49 +0200 Subject: [PATCH] fix(android): add notification app picker --- .../openclaw/app/ui/NotificationAppPicker.kt | 82 ++++++++ .../ai/openclaw/app/ui/SettingsScreens.kt | 182 ++++++++++++++++++ .../java/ai/openclaw/app/ui/SettingsSheet.kt | 76 -------- .../ui/SettingsSheetNotificationAppsTest.kt | 42 ++++ 4 files changed, 306 insertions(+), 76 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/NotificationAppPicker.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/NotificationAppPicker.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/NotificationAppPicker.kt new file mode 100644 index 000000000000..09ac2194ad8a --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/NotificationAppPicker.kt @@ -0,0 +1,82 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.node.DeviceNotificationListenerService +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager + +/** App entry shown in the notification-forwarding package picker. */ +data class InstalledApp( + val label: String, + val packageName: String, + val isSystemApp: Boolean, +) + +/** Reads launcher, recent-notification, and configured packages for the picker. */ +internal fun queryInstalledApps( + context: Context, + configuredPackages: Set, +): List { + val packageManager = context.packageManager + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } + + val launcherPackages = + packageManager + .queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL) + .asSequence() + .mapNotNull { + it.activityInfo + ?.packageName + ?.trim() + ?.takeIf(String::isNotEmpty) + }.toMutableSet() + + val recentNotificationPackages = + DeviceNotificationListenerService + .recentPackages(context) + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + + val candidatePackages = + resolveNotificationCandidatePackages( + launcherPackages = launcherPackages, + recentPackages = recentNotificationPackages, + configuredPackages = configuredPackages, + appPackageName = context.packageName, + ) + + return candidatePackages + .asSequence() + .mapNotNull { packageName -> + runCatching { + val appInfo = packageManager.getApplicationInfo(packageName, 0) + val label = packageManager.getApplicationLabel(appInfo).toString().trim() + InstalledApp( + label = if (label.isEmpty()) packageName else label, + packageName = packageName, + isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0, + ) + }.getOrNull() + }.sortedWith(compareBy { it.label.lowercase() }.thenBy { it.packageName }) + .toList() +} + +/** Merges package sources while excluding OpenClaw from its own forwarding filter. */ +internal fun resolveNotificationCandidatePackages( + launcherPackages: Set, + recentPackages: List, + configuredPackages: Set, + appPackageName: String, +): Set { + val blockedPackage = appPackageName.trim() + return sequenceOf( + configuredPackages.asSequence(), + launcherPackages.asSequence(), + recentPackages.asSequence(), + ).flatten() + .map { it.trim() } + .filter { it.isNotEmpty() && it != blockedPackage } + .toSet() +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index f7a5d866a662..7fa7720b4332 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -493,6 +493,8 @@ private fun playVoiceSetupTone() { Handler(Looper.getMainLooper()).postDelayed({ tone.release() }, 300L) } +private const val NOTIFICATION_PICKER_RESULT_LIMIT = 40 + @Composable private fun NotificationSettingsScreen( viewModel: MainViewModel, @@ -507,6 +509,19 @@ private fun NotificationSettingsScreen( val quietEnd by viewModel.notificationForwardingQuietEnd.collectAsState() val maxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState() val modeLabel = if (mode == NotificationPackageFilterMode.Blocklist) "Blocklist" else "Allowlist" + val installedApps = remember(context, packages) { queryInstalledApps(context, packages) } + var notificationPickerExpanded by remember { mutableStateOf(false) } + var notificationAppSearch by remember { mutableStateOf("") } + var notificationShowSystemApps by remember { mutableStateOf(false) } + val filteredApps = + remember(installedApps, packages, notificationAppSearch, notificationShowSystemApps) { + filterNotificationAppsForPicker( + apps = installedApps, + selectedPackages = packages, + query = notificationAppSearch, + showSystemApps = notificationShowSystemApps, + ) + } var listenerEnabled by remember { mutableStateOf(DeviceNotificationListenerService.isAccessEnabled(context)) } val notificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -567,6 +582,124 @@ private fun NotificationSettingsScreen( ) } } + NotificationPackagePickerPanel( + mode = mode, + selectedPackages = packages, + apps = filteredApps, + search = notificationAppSearch, + showSystemApps = notificationShowSystemApps, + expanded = notificationPickerExpanded, + onSearchChange = { notificationAppSearch = it }, + onShowSystemAppsChange = { notificationShowSystemApps = it }, + onExpandedChange = { notificationPickerExpanded = it }, + onPackageSelectionChange = { packageName, selected -> + val next = packages.toMutableSet() + if (selected) { + next.add(packageName) + } else { + next.remove(packageName) + } + viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(",")) + }, + ) + } +} + +@Composable +private fun NotificationPackagePickerPanel( + mode: NotificationPackageFilterMode, + selectedPackages: Set, + apps: List, + search: String, + showSystemApps: Boolean, + expanded: Boolean, + onSearchChange: (String) -> Unit, + onShowSystemAppsChange: (Boolean) -> Unit, + onExpandedChange: (Boolean) -> Unit, + onPackageSelectionChange: (String, Boolean) -> Unit, +) { + val visibleApps = apps.take(NOTIFICATION_PICKER_RESULT_LIMIT) + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(text = "App Filter", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text( + text = notificationPackageSelectionSummary(mode = mode, selectedCount = selectedPackages.size), + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + ClawSecondaryButton( + text = if (expanded) "Close App Picker" else "Open App Picker", + onClick = { onExpandedChange(!expanded) }, + modifier = Modifier.fillMaxWidth(), + ) + if (expanded) { + ClawTextField(value = search, onValueChange = onSearchChange, placeholder = "Search apps") + SettingsToggleListRow( + SettingsToggleRow( + title = "Show System Apps", + subtitle = "Include Android and background packages.", + icon = Icons.Default.Storage, + checked = showSystemApps, + onCheckedChange = onShowSystemAppsChange, + ), + ) + if (visibleApps.isEmpty()) { + Text(text = "No matching apps.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } else { + ClawSeparatedColumn(items = visibleApps) { app -> + NotificationPackageAppRow( + app = app, + selected = selectedPackages.contains(app.packageName), + onSelectedChange = { selected -> onPackageSelectionChange(app.packageName, selected) }, + ) + } + if (apps.size > visibleApps.size) { + Text( + text = "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } + } + } + } + } +} + +@Composable +private fun NotificationPackageAppRow( + app: InstalledApp, + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 58.dp) + .clickable { onSelectedChange(!selected) } + .padding(vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + ClawTextBadge(text = notificationAppBadge(app.label)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = app.label, + style = ClawTheme.type.body, + color = ClawTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = app.packageName, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Switch(checked = selected, onCheckedChange = onSelectedChange) } } @@ -1112,6 +1245,55 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus { } } +internal fun filterNotificationAppsForPicker( + apps: List, + selectedPackages: Set, + query: String, + showSystemApps: Boolean, +): List { + val normalizedQuery = query.trim().lowercase() + return apps.filter { app -> + val selected = app.packageName in selectedPackages + val visibleByType = showSystemApps || !app.isSystemApp || selected + val visibleBySearch = + normalizedQuery.isEmpty() || + app.label.lowercase().contains(normalizedQuery) || + app.packageName.lowercase().contains(normalizedQuery) + visibleByType && visibleBySearch + } +} + +private fun notificationPackageSelectionSummary( + mode: NotificationPackageFilterMode, + selectedCount: Int, +): String = + when (mode) { + NotificationPackageFilterMode.Allowlist -> + if (selectedCount == 0) { + "No apps selected. Nothing forwards until you add apps." + } else { + "$selectedCount ${if (selectedCount == 1) "app" else "apps"} allowed to forward." + } + NotificationPackageFilterMode.Blocklist -> + if (selectedCount == 0) { + "No apps blocked. Apps can forward unless you add blocks." + } else { + "$selectedCount ${if (selectedCount == 1) "app" else "apps"} blocked from forwarding." + } + } + +private fun notificationAppBadge(label: String): String { + val initials = + label + .split(' ', '-', '_', '.') + .asSequence() + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + return initials.ifBlank { "A" } +} + /** * Converts cron wake times into short relative labels for scheduled-work rows. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index 355dbf73422f..0caba6fbbf02 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -1222,82 +1222,6 @@ fun SettingsSheet(viewModel: MainViewModel) { } } -/** App entry shown in the notification-forwarding package picker. */ -data class InstalledApp( - val label: String, - val packageName: String, - val isSystemApp: Boolean, -) - -/** Reads launcher, recent-notification, and configured packages for the picker. */ -private fun queryInstalledApps( - context: Context, - configuredPackages: Set, -): List { - val packageManager = context.packageManager - val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } - - val launcherPackages = - packageManager - .queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL) - .asSequence() - .mapNotNull { - it.activityInfo - ?.packageName - ?.trim() - ?.takeIf(String::isNotEmpty) - }.toMutableSet() - - val recentNotificationPackages = - DeviceNotificationListenerService - .recentPackages(context) - .asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .toList() - - val candidatePackages = - resolveNotificationCandidatePackages( - launcherPackages = launcherPackages, - recentPackages = recentNotificationPackages, - configuredPackages = configuredPackages, - appPackageName = context.packageName, - ) - - return candidatePackages - .asSequence() - .mapNotNull { packageName -> - runCatching { - val appInfo = packageManager.getApplicationInfo(packageName, 0) - val label = packageManager.getApplicationLabel(appInfo).toString().trim() - InstalledApp( - label = if (label.isEmpty()) packageName else label, - packageName = packageName, - isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0, - ) - }.getOrNull() - }.sortedWith(compareBy { it.label.lowercase() }.thenBy { it.packageName }) - .toList() -} - -/** Merges package sources while excluding OpenClaw from its own forwarding filter. */ -internal fun resolveNotificationCandidatePackages( - launcherPackages: Set, - recentPackages: List, - configuredPackages: Set, - appPackageName: String, -): Set { - val blockedPackage = appPackageName.trim() - return sequenceOf( - configuredPackages.asSequence(), - launcherPackages.asSequence(), - recentPackages.asSequence(), - ).flatten() - .map { it.trim() } - .filter { it.isNotEmpty() && it != blockedPackage } - .toSet() -} - /** Shared Material text-field colors for the legacy mobile settings sheet. */ @Composable private fun settingsTextFieldColors() = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt index eab1bea2332e..effd5837c5c1 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt @@ -32,4 +32,46 @@ class SettingsSheetNotificationAppsTest { assertEquals(setOf("com.example.recent", "com.example.configured"), packages) } + + @Test + fun filterNotificationAppsForPicker_keepsSelectedSystemPackagesVisible() { + val apps = + listOf( + InstalledApp(label = "Android System", packageName = "android", isSystemApp = true), + InstalledApp(label = "Phone Services", packageName = "com.android.phone", isSystemApp = true), + InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false), + ) + + val filtered = + filterNotificationAppsForPicker( + apps = apps, + selectedPackages = setOf("com.android.phone"), + query = "", + showSystemApps = false, + ) + + assertEquals( + listOf("com.android.phone", "com.google.android.gm"), + filtered.map { it.packageName }, + ) + } + + @Test + fun filterNotificationAppsForPicker_matchesLabelsAndPackageNames() { + val apps = + listOf( + InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false), + InstalledApp(label = "Calendar", packageName = "com.google.android.calendar", isSystemApp = false), + ) + + val filtered = + filterNotificationAppsForPicker( + apps = apps, + selectedPackages = emptySet(), + query = "gm", + showSystemApps = false, + ) + + assertEquals(listOf("com.google.android.gm"), filtered.map { it.packageName }) + } }