fix(android): open system notifications on tap (#100888)

* fix(android): open system notifications on tap

* chore(android): sync notification i18n inventory
This commit is contained in:
Peter Steinberger
2026-07-06 14:14:29 +01:00
committed by GitHub
parent 5aa7c6267c
commit bc49cb41e9
6 changed files with 91 additions and 37 deletions

View File

@@ -179,7 +179,7 @@
},
{
"kind": "conditional-branch",
"line": 249,
"line": 239,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt",
"source": " · Mic: Listening",
"surface": "android",
@@ -187,7 +187,7 @@
},
{
"kind": "conditional-branch",
"line": 249,
"line": 239,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt",
"source": " · Mic: Pending",
"surface": "android",

View File

@@ -2,6 +2,8 @@
## Unreleased
Android system notifications now open OpenClaw when tapped without accepting arbitrary external deeplinks.
Android chat history now excludes internal, reasoning, and tool-result rows from rendered messages and the offline transcript cache.
Android chat messages now expose long-press actions for whole-message copy, selective text copy, sharing, and quoted replies.

View File

@@ -0,0 +1,22 @@
package ai.openclaw.app
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/** Reuses the existing app task when a system surface brings OpenClaw forward. */
internal fun mainActivityPendingIntent(
context: Context,
requestCode: Int,
): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}

View File

@@ -145,17 +145,7 @@ class NodeForegroundService : Service() {
title: String,
text: String,
): Notification {
val launchIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPending =
PendingIntent.getActivity(
this,
1,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val launchPending = mainActivityPendingIntent(this, requestCode = 1)
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val stopPending =

View File

@@ -1,7 +1,9 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.mainActivityPendingIntent
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
@@ -16,6 +18,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
private const val NOTIFICATION_CONTENT_REQUEST_CODE = 3
/** Parsed payload for system.notify invocations. */
internal data class SystemNotifyRequest(
@@ -49,18 +52,7 @@ private class AndroidSystemNotificationPoster(
/** 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 silent = isSilentSound(request.sound)
val notification =
NotificationCompat
.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(request.title)
.setContentText(request.body)
.setPriority(compatPriority(request.priority))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setSilent(silent)
.build()
val notification = buildSystemNotification(appContext, channelId, request)
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) !=
@@ -89,20 +81,37 @@ private class AndroidSystemNotificationPoster(
}
return channelId
}
private fun compatPriority(priority: String?): Int =
when (priority.orEmpty().trim().lowercase()) {
"passive" -> NotificationCompat.PRIORITY_LOW
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
}
private fun isSilentSound(sound: String?): Boolean {
val normalized = sound?.trim()?.lowercase() ?: return false
return normalized in setOf("none", "silent", "off", "false", "0")
}
}
private fun compatPriority(priority: String?): Int =
when (priority.orEmpty().trim().lowercase()) {
"passive" -> NotificationCompat.PRIORITY_LOW
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
}
private fun isSilentSound(sound: String?): Boolean {
val normalized = sound?.trim()?.lowercase() ?: return false
return normalized in setOf("none", "silent", "off", "false", "0")
}
internal fun buildSystemNotification(
appContext: Context,
channelId: String,
request: SystemNotifyRequest,
): Notification =
NotificationCompat
.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(request.title)
.setContentText(request.body)
.setContentIntent(mainActivityPendingIntent(appContext, NOTIFICATION_CONTENT_REQUEST_CODE))
.setPriority(compatPriority(request.priority))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setSilent(isSilentSound(request.sound))
.build()
/** Handles system-level node.invoke commands implemented by Android services. */
class SystemHandler private constructor(
private val poster: SystemNotificationPoster,

View File

@@ -1,10 +1,21 @@
package ai.openclaw.app.node
import ai.openclaw.app.MainActivity
import android.content.Context
import android.content.Intent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SystemHandlerTest {
@Test
fun handleSystemNotify_rejectsUnauthorized() {
@@ -64,6 +75,26 @@ class SystemHandlerTest {
assertEquals("silent", poster.lastRequest?.sound)
}
@Test
fun buildSystemNotificationSetsImmutableAppLaunchIntent() {
val context: Context = RuntimeEnvironment.getApplication()
val notification =
buildSystemNotification(
appContext = context,
channelId = "test",
request = SystemNotifyRequest("OpenClaw", "done", sound = null, priority = null),
)
val pendingIntent = notification.contentIntent
assertNotNull(pendingIntent)
assertTrue(pendingIntent.isImmutable)
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
}
@Test
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))