From f637cbd2461b8af2b0079051d83d00cf2d30e221 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 28 Feb 2026 09:14:22 +0530 Subject: [PATCH] feat(android): add system notification handler --- .../ai/openclaw/android/node/SystemHandler.kt | 162 ++++++++++++++++++ .../android/node/SystemHandlerTest.kt | 52 ++++++ 2 files changed, 214 insertions(+) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt new file mode 100644 index 00000000000..171cd45fd95 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/SystemHandler.kt @@ -0,0 +1,162 @@ +package ai.openclaw.android.node + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify" + +internal data class SystemNotifyRequest( + val title: String, + val body: String, + val sound: String?, + val priority: String?, +) + +internal interface SystemNotificationPoster { + fun isAuthorized(): Boolean + + fun post(request: SystemNotifyRequest) +} + +private class AndroidSystemNotificationPoster( + private val appContext: Context, +) : SystemNotificationPoster { + override fun isAuthorized(): Boolean { + if (Build.VERSION.SDK_INT >= 33) { + val granted = + ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) return false + } + return NotificationManagerCompat.from(appContext).areNotificationsEnabled() + } + + 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() + NotificationManagerCompat.from(appContext).notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), notification) + } + + private fun ensureChannel(priority: String?): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return NOTIFICATION_CHANNEL_BASE_ID + } + val normalizedPriority = priority.orEmpty().trim().lowercase() + val (suffix, importance, name) = + when (normalizedPriority) { + "passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive") + "timesensitive" -> Triple("timesensitive", NotificationManager.IMPORTANCE_HIGH, "OpenClaw Time Sensitive") + else -> Triple("active", NotificationManager.IMPORTANCE_DEFAULT, "OpenClaw Active") + } + val channelId = "$NOTIFICATION_CHANNEL_BASE_ID.$suffix" + val manager = appContext.getSystemService(NotificationManager::class.java) + val existing = manager.getNotificationChannel(channelId) + if (existing == null) { + manager.createNotificationChannel(NotificationChannel(channelId, name, importance)) + } + return channelId + } + + private fun compatPriority(priority: String?): Int { + return 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") + } +} + +class SystemHandler private constructor( + private val poster: SystemNotificationPoster, +) { + constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext)) + + fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult { + val params = + parseNotifyRequest(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: expected JSON object with title/body", + ) + if (params.title.isEmpty() && params.body.isEmpty()) { + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + 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 (err: Throwable) { + GatewaySession.InvokeResult.error( + code = "UNAVAILABLE", + message = "NOTIFICATION_FAILED: ${err.message ?: "notification post failed"}", + ) + } + } + + private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? { + val params = parseParamsObject(paramsJson) ?: return null + val rawTitle = + (params["title"] as? JsonPrimitive) + ?.contentOrNull + ?: return null + val rawBody = + (params["body"] as? JsonPrimitive) + ?.contentOrNull + ?: return null + val sound = (params["sound"] as? JsonPrimitive)?.contentOrNull + val priority = (params["priority"] as? JsonPrimitive)?.contentOrNull + return SystemNotifyRequest( + title = rawTitle.trim(), + body = rawBody.trim(), + sound = sound?.trim()?.ifEmpty { null }, + priority = priority?.trim()?.ifEmpty { null }, + ) + } + + private fun parseParamsObject(paramsJson: String?): JsonObject? { + if (paramsJson.isNullOrBlank()) return null + return try { + Json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } + } + + companion object { + internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt new file mode 100644 index 00000000000..7b49a3641e0 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/SystemHandlerTest.kt @@ -0,0 +1,52 @@ +package ai.openclaw.android.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SystemHandlerTest { + @Test + fun handleSystemNotify_rejectsUnauthorized() { + val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false)) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""") + + assertFalse(result.ok) + assertEquals("NOT_AUTHORIZED", result.error?.code) + } + + @Test + fun handleSystemNotify_rejectsEmptyNotification() { + val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true)) + + val result = handler.handleSystemNotify("""{"title":" ","body":" "}""") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleSystemNotify_postsNotification() { + val poster = FakePoster(authorized = true) + val handler = SystemHandler.forTesting(poster = poster) + + val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""") + + assertTrue(result.ok) + assertEquals(1, poster.posts) + } +} + +private class FakePoster( + private val authorized: Boolean, +) : SystemNotificationPoster { + var posts: Int = 0 + private set + + override fun isAuthorized(): Boolean = authorized + + override fun post(request: SystemNotifyRequest) { + posts += 1 + } +}