fix(android): report disabled notification categories instead of success (#130531)

This commit is contained in:
Peter Steinberger
2026-08-26 18:06:31 -07:00
committed by GitHub
parent fc51e97e60
commit a369c7ca02
3 changed files with 79 additions and 42 deletions
@@ -29,29 +29,14 @@ internal data class SystemNotifyRequest(
/** Notification posting seam used by production Android and unit tests. */
internal interface SystemNotificationPoster {
fun isAuthorized(): Boolean
fun post(request: SystemNotifyRequest)
}
private class AndroidSystemNotificationPoster(
private val appContext: Context,
) : SystemNotificationPoster {
/** Checks both Android 13 runtime permission and app-level notification enablement. */
override fun isAuthorized(): Boolean {
if (Build.VERSION.SDK_INT >= 33) {
val granted =
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) return false
}
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
}
/** Posts through a priority-specific channel so Android's immutable channel importance is respected. */
override fun post(request: SystemNotifyRequest) {
val channelId = ensureChannel(request.priority)
val notification = buildSystemNotification(appContext, channelId, request)
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) !=
@@ -59,6 +44,11 @@ private class AndroidSystemNotificationPoster(
) {
throw SecurityException("notifications permission missing")
}
if (!NotificationManagerCompat.from(appContext).areNotificationsEnabled()) {
throw SecurityException("notifications disabled")
}
val channelId = ensureChannel(request.priority)
val notification = buildSystemNotification(appContext, channelId, request)
NotificationManagerCompat.from(appContext).notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), notification)
}
@@ -82,6 +72,10 @@ private class AndroidSystemNotificationPoster(
val channelId = "$NOTIFICATION_CHANNEL_BASE_ID.$suffix"
val manager = appContext.getSystemService(NotificationManager::class.java)
val existing = manager.getNotificationChannel(channelId)
// notify() silently drops blocked channels; report the user's existing choice before posting.
if (existing?.importance == NotificationManager.IMPORTANCE_NONE) {
throw SecurityException("notification channel disabled")
}
if (existing == null) {
manager.createNotificationChannel(NotificationChannel(channelId, name, importance))
}
@@ -138,19 +132,13 @@ class SystemHandler private constructor(
message = "INVALID_REQUEST: empty notification",
)
}
if (!poster.isAuthorized()) {
return GatewaySession.InvokeResult.error(
code = "NOT_AUTHORIZED",
message = "NOT_AUTHORIZED: notifications",
)
}
return try {
poster.post(params)
GatewaySession.InvokeResult.ok(null)
} catch (_: SecurityException) {
GatewaySession.InvokeResult.error(
code = "NOT_AUTHORIZED",
message = "NOT_AUTHORIZED: notifications",
message = "NOT_AUTHORIZED: enable OpenClaw notifications and the selected priority in Android Settings",
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
@@ -386,8 +386,6 @@ private class InvokeDispatcherFakeNotificationsStateProvider : NotificationsStat
}
private class InvokeDispatcherFakeSystemNotificationPoster : SystemNotificationPoster {
override fun isAuthorized(): Boolean = true
override fun post(request: SystemNotifyRequest) = Unit
}
@@ -1,6 +1,10 @@
package ai.openclaw.app.node
import ai.openclaw.app.MainActivity
import android.Manifest
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import org.junit.Assert.assertEquals
@@ -19,17 +23,30 @@ import org.robolectric.annotation.Config
class SystemHandlerTest {
@Test
fun handleSystemNotify_rejectsUnauthorized() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false))
val context: Application = RuntimeEnvironment.getApplication()
val manager = context.getSystemService(NotificationManager::class.java)
val handler = SystemHandler(context)
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""")
for (permissionGranted in listOf(false, true)) {
if (permissionGranted) {
Shadows.shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
} else {
Shadows.shadowOf(context).denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
}
Shadows.shadowOf(manager).setNotificationsEnabled(!permissionGranted)
assertFalse(result.ok)
assertEquals("NOT_AUTHORIZED", result.error?.code)
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""")
assertFalse(result.ok)
assertEquals("NOT_AUTHORIZED", result.error?.code)
assertTrue(manager.notificationChannels.isEmpty())
assertTrue(manager.activeNotifications.isEmpty())
}
}
@Test
fun handleSystemNotify_rejectsEmptyNotification() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
val handler = SystemHandler.forTesting(poster = FakePoster())
val result = handler.handleSystemNotify("""{"title":" ","body":" "}""")
@@ -39,7 +56,7 @@ class SystemHandlerTest {
@Test
fun handleSystemNotify_rejectsInvalidRequestObject() {
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
val handler = SystemHandler.forTesting(poster = FakePoster())
val result = handler.handleSystemNotify("""{"title":"OpenClaw"}""")
@@ -49,7 +66,7 @@ class SystemHandlerTest {
@Test
fun handleSystemNotify_postsNotification() {
val poster = FakePoster(authorized = true)
val poster = FakePoster()
val handler = SystemHandler.forTesting(poster = poster)
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""")
@@ -58,9 +75,50 @@ class SystemHandlerTest {
assertEquals(1, poster.posts)
}
@Test
fun handleSystemNotify_rejectsBlockedSelectedChannels() {
val context: Application = RuntimeEnvironment.getApplication()
Shadows.shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
val manager = context.getSystemService(NotificationManager::class.java)
val handler = SystemHandler(context)
assertTrue(manager.areNotificationsEnabled())
val priorities = listOf(null to "active", "active" to "active", "passive" to "passive", "timeSensitive" to "timesensitive")
for ((priority, suffix) in priorities) {
val channelId = "openclaw.system.notify.$suffix"
manager.createNotificationChannel(NotificationChannel(channelId, "Blocked", NotificationManager.IMPORTANCE_NONE))
val priorityField = priority?.let { ",\"priority\":\"$it\"" }.orEmpty()
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"blocked"$priorityField}""")
assertFalse("priority=$priority must not report a blocked post as successful", result.ok)
assertEquals("NOT_AUTHORIZED", result.error?.code)
assertEquals(NotificationManager.IMPORTANCE_NONE, manager.getNotificationChannel(channelId).importance)
}
}
@Test
fun handleSystemNotify_postsAllowedChannelWhenAnotherPriorityIsBlocked() {
val context: Application = RuntimeEnvironment.getApplication()
Shadows.shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel("openclaw.system.notify.active", "Blocked", NotificationManager.IMPORTANCE_NONE),
)
val handler = SystemHandler(context)
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"allowed","priority":"passive"}""")
assertTrue(result.ok)
assertEquals(NotificationManager.IMPORTANCE_LOW, manager.getNotificationChannel("openclaw.system.notify.passive").importance)
val notification = manager.activeNotifications.single().notification
assertEquals("openclaw.system.notify.passive", notification.channelId)
assertEquals("allowed", notification.extras.getCharSequence("android.text"))
}
@Test
fun handleSystemNotify_trimsAndPassesOptionalFields() {
val poster = FakePoster(authorized = true)
val poster = FakePoster()
val handler = SystemHandler.forTesting(poster = poster)
val result =
@@ -97,7 +155,7 @@ class SystemHandlerTest {
@Test
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))
val handler = SystemHandler.forTesting(poster = ThrowingPoster(error = SecurityException("denied")))
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
@@ -107,7 +165,7 @@ class SystemHandlerTest {
@Test
fun handleSystemNotify_returnsUnavailableWhenPostFailsUnexpectedly() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = IllegalStateException("boom")))
val handler = SystemHandler.forTesting(poster = ThrowingPoster(error = IllegalStateException("boom")))
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
@@ -117,16 +175,12 @@ class SystemHandlerTest {
}
}
private class FakePoster(
private val authorized: Boolean,
) : SystemNotificationPoster {
private class FakePoster : SystemNotificationPoster {
var posts: Int = 0
private set
var lastRequest: SystemNotifyRequest? = null
private set
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest) {
posts += 1
lastRequest = request
@@ -134,10 +188,7 @@ private class FakePoster(
}
private class ThrowingPoster(
private val authorized: Boolean,
private val error: Throwable,
) : SystemNotificationPoster {
override fun isAuthorized(): Boolean = authorized
override fun post(request: SystemNotifyRequest): Unit = throw error
}