From d001975d31151e95a7e073eea563de9d8cd7835d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 09:38:10 -0700 Subject: [PATCH] fix(android): require listener access before forwarding notifications (#130173) * fix(android): require listener access before forwarding notifications * test(android): use activity-local owner in notification settings test --- .../ai/openclaw/app/ui/SettingsScreens.kt | 8 +- .../app/ui/NotificationSettingsScreenTest.kt | 184 ++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/ui/NotificationSettingsScreenTest.kt 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 139c3f4d8f6c..1400798236c3 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 @@ -1079,7 +1079,7 @@ private fun NotificationSettingsScreen( val notificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - viewModel.setNotificationForwardingEnabled(granted) + viewModel.setNotificationForwardingEnabled(granted && DeviceNotificationListenerService.isAccessEnabled(context)) } fun setForwarding(checked: Boolean) { @@ -1087,12 +1087,16 @@ private fun NotificationSettingsScreen( viewModel.setNotificationForwardingEnabled(false) return } + listenerEnabled = DeviceNotificationListenerService.isAccessEnabled(context) + if (!listenerEnabled) { + openNotificationListenerSettings(context) + return + } if (Build.VERSION.SDK_INT >= 33 && !hasPermission(context, Manifest.permission.POST_NOTIFICATIONS)) { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { viewModel.setNotificationForwardingEnabled(true) } - listenerEnabled = DeviceNotificationListenerService.isAccessEnabled(context) } SettingsDetailFrame(title = nativeString("Notifications"), subtitle = nativeString("Choose what reaches OpenClaw."), icon = Icons.Default.Notifications, onBack = onBack) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/NotificationSettingsScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/NotificationSettingsScreenTest.kt new file mode 100644 index 000000000000..f1ceb812f619 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/NotificationSettingsScreenTest.kt @@ -0,0 +1,184 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.NodeApp +import ai.openclaw.app.NodeRuntime +import ai.openclaw.app.NodeRuntimeMode +import ai.openclaw.app.SecurePrefs +import ai.openclaw.app.node.DeviceNotificationListenerService +import ai.openclaw.app.ui.design.ClawDesignTheme +import android.Manifest +import android.app.Activity +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Context +import android.provider.Settings +import androidx.activity.compose.LocalActivity +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowNotificationManager +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NotificationSettingsScreenTest { + @get:Rule + val composeRule = createComposeRule() + + private lateinit var app: NodeApp + private lateinit var prefs: SecurePrefs + private lateinit var runtime: NodeRuntime + private lateinit var activity: Activity + private var originalRuntime: NodeRuntime? = null + + @Before + fun setUp() { + app = RuntimeEnvironment.getApplication() as NodeApp + clearPlainPreferences() + prefs = + SecurePrefs( + app, + app.getSharedPreferences("notification-settings-${UUID.randomUUID()}", Context.MODE_PRIVATE), + ) + runtime = NodeRuntime(app, prefs, NodeRuntimeMode.ScreenshotFixture) + originalRuntime = app.peekRuntime() + setApplicationRuntime(runtime) + shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + setListenerAccess(granted = false) + } + + @After + fun tearDown() { + setApplicationRuntime(originalRuntime) + runtime.disconnect() + clearPlainPreferences() + } + + @Test + fun enablingWithoutListenerAccessKeepsForwardingDisabledAndOpensSystemAccess() { + showNotificationSettings() + + clickForwarding() + + assertListenerSetupRequired() + } + + @Test + fun listenerAccessIsRequestedBeforeNotificationPostingPermission() { + shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + showNotificationSettings() + + clickForwarding() + + assertListenerSetupRequired() + } + + @Test + @Config(sdk = [31]) + fun olderAndroidAlsoRequiresListenerAccessBeforeEnablingForwarding() { + showNotificationSettings() + + clickForwarding() + + assertListenerSetupRequired() + } + + @Test + fun listenerRevokedAfterScreenMountCannotEnableForwarding() { + setListenerAccess(granted = true) + showNotificationSettings() + setListenerAccess(granted = false) + + clickForwarding() + + assertListenerSetupRequired() + } + + @Test + fun grantedListenerAccessAllowsForwardingWithoutOpeningSystemAccess() { + setListenerAccess(granted = true) + showNotificationSettings() + + clickForwarding() + + assertTrue(prefs.notificationForwardingEnabled.value) + assertNull(shadowOf(activity).nextStartedActivity) + } + + @Test + fun disablingForwardingDoesNotRequireListenerAccess() { + prefs.setNotificationForwardingEnabled(true) + showNotificationSettings() + + clickForwarding() + + assertFalse(prefs.notificationForwardingEnabled.value) + assertNull(shadowOf(activity).nextStartedActivity) + } + + private fun showNotificationSettings() { + val viewModel = MainViewModel(app, prefs, SavedStateHandle()) + composeRule.setContent { + activity = requireNotNull(LocalActivity.current) + ClawDesignTheme { + SettingsDetailScreen( + viewModel = viewModel, + route = SettingsRoute.Notifications, + onBack = {}, + ) + } + } + } + + private fun clickForwarding() { + composeRule.onNodeWithText("Forward Notifications").performClick() + composeRule.waitForIdle() + } + + private fun assertListenerSetupRequired() { + assertFalse(prefs.notificationForwardingEnabled.value) + assertEquals( + Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS, + shadowOf(activity).nextStartedActivity?.action, + ) + } + + private fun setListenerAccess(granted: Boolean) { + val manager = app.getSystemService(NotificationManager::class.java) + Shadow.extract(manager).setNotificationListenerAccessGranted( + ComponentName(app, DeviceNotificationListenerService::class.java), + granted, + ) + } + + private fun setApplicationRuntime(value: NodeRuntime?) { + NodeApp::class.java + .getDeclaredField("runtimeInstance") + .apply { isAccessible = true } + .set(app, value) + } + + private fun clearPlainPreferences() { + app + .getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } +}