mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 03:28:02 +08:00
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:
committed by
GitHub
parent
5aa7c6267c
commit
bc49cb41e9
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")))
|
||||
|
||||
Reference in New Issue
Block a user