diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index d15aa82de128..11007f28ed84 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index b22ae96c7421..6d75a811b01e 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt new file mode 100644 index 000000000000..48f0100d1d7b --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivityPendingIntent.kt @@ -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, + ) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index baee72f863fb..38f2020092b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -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 = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt index 7e5b4fe5ef26..3f79c3d5d920 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SystemHandler.kt @@ -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, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt index 0f2b0c7dc41e..460f9c8913da 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/SystemHandlerTest.kt @@ -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")))