diff --git a/CHANGELOG.md b/CHANGELOG.md
index 540a1ad2caa..8baf1028bfa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.
- Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.
- OpenAI/Responses: forward configured `text.verbosity` across Responses HTTP and WebSocket transports, surface it in `/status`, and keep per-agent verbosity precedence aligned with runtime behavior. (#47106) Thanks @merc1305 and @vincentkoc.
+- Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.
### Fixes
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 283daae601f..31a9fb6e1e7 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -31,6 +31,13 @@
android:name="android.hardware.telephony"
android:required="false" />
+
+
+
+
+
+
+
> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow = runtimeState(initial = "Searching…") { it.discoveryStatusText }
+ val notificationForwardingEnabled: StateFlow = prefs.notificationForwardingEnabled
+ val notificationForwardingMode: StateFlow =
+ prefs.notificationForwardingMode
+ val notificationForwardingPackages: StateFlow> = prefs.notificationForwardingPackages
+ val notificationForwardingQuietHoursEnabled: StateFlow =
+ prefs.notificationForwardingQuietHoursEnabled
+ val notificationForwardingQuietStart: StateFlow = prefs.notificationForwardingQuietStart
+ val notificationForwardingQuietEnd: StateFlow = prefs.notificationForwardingQuietEnd
+ val notificationForwardingMaxEventsPerMinute: StateFlow =
+ prefs.notificationForwardingMaxEventsPerMinute
+ val notificationForwardingSessionKey: StateFlow = prefs.notificationForwardingSessionKey
val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected }
@@ -197,6 +208,39 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
prefs.setCanvasDebugStatusEnabled(value)
}
+ fun setNotificationForwardingEnabled(value: Boolean) {
+ ensureRuntime().setNotificationForwardingEnabled(value)
+ }
+
+ fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
+ ensureRuntime().setNotificationForwardingMode(mode)
+ }
+
+ fun setNotificationForwardingPackagesCsv(csv: String) {
+ val packages =
+ csv
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ ensureRuntime().setNotificationForwardingPackages(packages)
+ }
+
+ fun setNotificationForwardingQuietHours(
+ enabled: Boolean,
+ start: String,
+ end: String,
+ ): Boolean {
+ return ensureRuntime().setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
+ }
+
+ fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
+ ensureRuntime().setNotificationForwardingMaxEventsPerMinute(value)
+ }
+
+ fun setNotificationForwardingSessionKey(value: String?) {
+ ensureRuntime().setNotificationForwardingSessionKey(value)
+ }
+
fun setVoiceScreenActive(active: Boolean) {
ensureRuntime().setVoiceScreenActive(active)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
index 37bafd8283f..732722658c6 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
@@ -534,6 +534,17 @@ class NodeRuntime(
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled
+ val notificationForwardingEnabled: StateFlow = prefs.notificationForwardingEnabled
+ val notificationForwardingMode: StateFlow =
+ prefs.notificationForwardingMode
+ val notificationForwardingPackages: StateFlow> = prefs.notificationForwardingPackages
+ val notificationForwardingQuietHoursEnabled: StateFlow =
+ prefs.notificationForwardingQuietHoursEnabled
+ val notificationForwardingQuietStart: StateFlow = prefs.notificationForwardingQuietStart
+ val notificationForwardingQuietEnd: StateFlow = prefs.notificationForwardingQuietEnd
+ val notificationForwardingMaxEventsPerMinute: StateFlow =
+ prefs.notificationForwardingMaxEventsPerMinute
+ val notificationForwardingSessionKey: StateFlow = prefs.notificationForwardingSessionKey
private var didAutoConnect = false
@@ -686,6 +697,34 @@ class NodeRuntime(
prefs.setCanvasDebugStatusEnabled(value)
}
+ fun setNotificationForwardingEnabled(value: Boolean) {
+ prefs.setNotificationForwardingEnabled(value)
+ }
+
+ fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
+ prefs.setNotificationForwardingMode(mode)
+ }
+
+ fun setNotificationForwardingPackages(packages: List) {
+ prefs.setNotificationForwardingPackages(packages)
+ }
+
+ fun setNotificationForwardingQuietHours(
+ enabled: Boolean,
+ start: String,
+ end: String,
+ ): Boolean {
+ return prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
+ }
+
+ fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
+ prefs.setNotificationForwardingMaxEventsPerMinute(value)
+ }
+
+ fun setNotificationForwardingSessionKey(value: String?) {
+ prefs.setNotificationForwardingSessionKey(value)
+ }
+
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
stopActiveVoiceSession()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt b/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt
new file mode 100644
index 00000000000..12b843b3942
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NotificationForwardingPolicy.kt
@@ -0,0 +1,102 @@
+package ai.openclaw.app
+
+import java.time.Instant
+import java.time.ZoneId
+
+enum class NotificationPackageFilterMode(val rawValue: String) {
+ Allowlist("allowlist"),
+ Blocklist("blocklist"),
+ ;
+
+ companion object {
+ fun fromRawValue(raw: String?): NotificationPackageFilterMode {
+ return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
+ }
+ }
+}
+
+internal data class NotificationForwardingPolicy(
+ val enabled: Boolean,
+ val mode: NotificationPackageFilterMode,
+ val packages: Set,
+ val quietHoursEnabled: Boolean,
+ val quietStart: String,
+ val quietEnd: String,
+ val maxEventsPerMinute: Int,
+ val sessionKey: String?,
+)
+
+internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
+ val normalized = packageName.trim()
+ if (normalized.isEmpty()) {
+ return false
+ }
+ return when (mode) {
+ NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
+ NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
+ }
+}
+
+internal fun NotificationForwardingPolicy.isWithinQuietHours(
+ nowEpochMs: Long,
+ zoneId: ZoneId = ZoneId.systemDefault(),
+): Boolean {
+ if (!quietHoursEnabled) {
+ return false
+ }
+ val startMinutes = parseLocalHourMinute(quietStart) ?: return false
+ val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
+ if (startMinutes == endMinutes) {
+ return true
+ }
+ val now =
+ Instant.ofEpochMilli(nowEpochMs)
+ .atZone(zoneId)
+ .toLocalTime()
+ val nowMinutes = now.hour * 60 + now.minute
+ return if (startMinutes < endMinutes) {
+ nowMinutes in startMinutes until endMinutes
+ } else {
+ nowMinutes >= startMinutes || nowMinutes < endMinutes
+ }
+}
+
+private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
+
+internal fun normalizeLocalHourMinute(raw: String): String? {
+ val trimmed = raw.trim()
+ val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
+ return "${match.groupValues[1]}:${match.groupValues[2]}"
+}
+
+internal fun parseLocalHourMinute(raw: String): Int? {
+ val normalized = normalizeLocalHourMinute(raw) ?: return null
+ val parts = normalized.split(':')
+ val hour = parts[0].toInt()
+ val minute = parts[1].toInt()
+ return hour * 60 + minute
+}
+
+internal class NotificationBurstLimiter {
+ private val lock = Any()
+ private var windowStartMs: Long = -1L
+ private var eventsInWindow: Int = 0
+
+ fun allow(nowEpochMs: Long, maxEventsPerMinute: Int): Boolean {
+ if (maxEventsPerMinute <= 0) {
+ return false
+ }
+ val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
+ synchronized(lock) {
+ if (currentWindow != windowStartMs) {
+ windowStartMs = currentWindow
+ eventsInWindow = 0
+ }
+ if (eventsInWindow >= maxEventsPerMinute) {
+ return false
+ }
+ eventsInWindow += 1
+ return true
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
index a1aabeb1b3c..e18c33bb225 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt
@@ -26,6 +26,17 @@ class SecurePrefs(
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
+ private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
+ private const val defaultNotificationForwardingEnabled = false
+ private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
+ private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
+ private const val notificationsForwardingQuietHoursEnabledKey =
+ "notifications.forwarding.quietHoursEnabled"
+ private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
+ private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
+ private const val notificationsForwardingMaxEventsPerMinuteKey =
+ "notifications.forwarding.maxEventsPerMinute"
+ private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
}
private val appContext = context.applicationContext
@@ -96,6 +107,55 @@ class SecurePrefs(
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled
+ private val _notificationForwardingEnabled =
+ MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
+ val notificationForwardingEnabled: StateFlow = _notificationForwardingEnabled
+
+ private val _notificationForwardingMode =
+ MutableStateFlow(
+ NotificationPackageFilterMode.fromRawValue(
+ plainPrefs.getString(notificationsForwardingModeKey, null),
+ ),
+ )
+ val notificationForwardingMode: StateFlow = _notificationForwardingMode
+
+ private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
+ val notificationForwardingPackages: StateFlow> = _notificationForwardingPackages
+
+ private val storedQuietStart =
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
+ ?: "22:00"
+ private val storedQuietEnd =
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
+ ?: "07:00"
+ private val storedQuietHoursEnabled =
+ plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
+
+ private val _notificationForwardingQuietHoursEnabled =
+ MutableStateFlow(storedQuietHoursEnabled)
+ val notificationForwardingQuietHoursEnabled: StateFlow = _notificationForwardingQuietHoursEnabled
+
+ private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
+ val notificationForwardingQuietStart: StateFlow = _notificationForwardingQuietStart
+
+ private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
+ val notificationForwardingQuietEnd: StateFlow = _notificationForwardingQuietEnd
+
+ private val _notificationForwardingMaxEventsPerMinute =
+ MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
+ val notificationForwardingMaxEventsPerMinute: StateFlow = _notificationForwardingMaxEventsPerMinute
+
+ private val _notificationForwardingSessionKey =
+ MutableStateFlow(
+ plainPrefs
+ .getString(notificationsForwardingSessionKeyKey, "")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() },
+ )
+ val notificationForwardingSessionKey: StateFlow = _notificationForwardingSessionKey
+
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow> = _wakeWords
@@ -185,6 +245,114 @@ class SecurePrefs(
_canvasDebugStatusEnabled.value = value
}
+ internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
+ val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
+ val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
+
+ val configuredPackages = loadNotificationForwardingPackages()
+ val normalizedAppPackage = appPackageName.trim()
+ val defaultBlockedPackages =
+ if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
+
+ val packages =
+ when (mode) {
+ NotificationPackageFilterMode.Allowlist -> configuredPackages
+ NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
+ }
+
+ val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
+ val quietStart =
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
+ ?: "22:00"
+ val quietEnd =
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
+ ?: "07:00"
+ val sessionKey =
+ plainPrefs
+ .getString(notificationsForwardingSessionKeyKey, "")
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+
+ val quietHoursEnabled =
+ plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
+ normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
+
+ return NotificationForwardingPolicy(
+ enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
+ mode = mode,
+ packages = packages,
+ quietHoursEnabled = quietHoursEnabled,
+ quietStart = quietStart,
+ quietEnd = quietEnd,
+ maxEventsPerMinute = maxEvents.coerceAtLeast(1),
+ sessionKey = sessionKey,
+ )
+ }
+
+ internal fun setNotificationForwardingEnabled(value: Boolean) {
+ plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
+ _notificationForwardingEnabled.value = value
+ }
+
+ internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
+ plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
+ _notificationForwardingMode.value = mode
+ }
+
+ internal fun setNotificationForwardingPackages(packages: List) {
+ val sanitized =
+ packages
+ .asSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toSet()
+ .toList()
+ .sorted()
+ val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
+ plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
+ _notificationForwardingPackages.value = sanitized.toSet()
+ }
+
+ internal fun setNotificationForwardingQuietHours(
+ enabled: Boolean,
+ start: String,
+ end: String,
+ ): Boolean {
+ if (!enabled) {
+ plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
+ _notificationForwardingQuietHoursEnabled.value = false
+ return true
+ }
+ val normalizedStart = normalizeLocalHourMinute(start) ?: return false
+ val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
+ plainPrefs.edit {
+ putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
+ putString(notificationsForwardingQuietStartKey, normalizedStart)
+ putString(notificationsForwardingQuietEndKey, normalizedEnd)
+ }
+ _notificationForwardingQuietHoursEnabled.value = enabled
+ _notificationForwardingQuietStart.value = normalizedStart
+ _notificationForwardingQuietEnd.value = normalizedEnd
+ return true
+ }
+
+ internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
+ val normalized = value.coerceAtLeast(1)
+ plainPrefs.edit {
+ putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
+ }
+ _notificationForwardingMaxEventsPerMinute.value = normalized
+ }
+
+ internal fun setNotificationForwardingSessionKey(value: String?) {
+ val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
+ plainPrefs.edit {
+ putString(notificationsForwardingSessionKeyKey, normalized.orEmpty())
+ }
+ _notificationForwardingSessionKey.value = normalized
+ }
+
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
@@ -308,6 +476,28 @@ class SecurePrefs(
_speakerEnabled.value = value
}
+ private fun loadNotificationForwardingPackages(): Set {
+ val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
+ if (raw.isNullOrEmpty()) {
+ return emptySet()
+ }
+ return try {
+ val element = json.parseToJsonElement(raw)
+ val array = element as? JsonArray ?: return emptySet()
+ array
+ .mapNotNull { item ->
+ when (item) {
+ is JsonNull -> null
+ is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
+ else -> null
+ }
+ }
+ .toSet()
+ } catch (_: Throwable) {
+ emptySet()
+ }
+ }
+
private fun loadVoiceWakeMode(): VoiceWakeMode {
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
index 55e371a57c7..fff8ec843c9 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
@@ -181,17 +181,10 @@ class GatewaySession(
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
- val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
- if (parsedPayload != null) {
- put("payload", parsedPayload)
- } else if (payloadJson != null) {
- put("payloadJSON", JsonPrimitive(payloadJson))
- } else {
- put("payloadJSON", JsonNull)
- }
+ put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
index 1e9dc0408f6..a1a9433e4a2 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceNotificationListenerService.kt
@@ -8,6 +8,10 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
+import ai.openclaw.app.NotificationBurstLimiter
+import ai.openclaw.app.SecurePrefs
+import ai.openclaw.app.allowsPackage
+import ai.openclaw.app.isWithinQuietHours
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -126,6 +130,9 @@ private object DeviceNotificationStore {
}
class DeviceNotificationListenerService : NotificationListenerService() {
+ private val securePrefs by lazy { SecurePrefs(applicationContext) }
+ private val forwardingLimiter = NotificationBurstLimiter()
+
override fun onListenerConnected() {
super.onListenerConnected()
activeService = this
@@ -152,24 +159,12 @@ class DeviceNotificationListenerService : NotificationListenerService() {
super.onNotificationPosted(sbn)
val entry = sbn?.toEntry() ?: return
DeviceNotificationStore.upsert(entry)
+ rememberRecentPackage(entry.packageName)
if (entry.packageName == packageName) {
return
}
- emitNotificationsChanged(
- buildJsonObject {
- put("change", JsonPrimitive("posted"))
- put("key", JsonPrimitive(entry.key))
- put("packageName", JsonPrimitive(entry.packageName))
- put("postTimeMs", JsonPrimitive(entry.postTimeMs))
- put("isOngoing", JsonPrimitive(entry.isOngoing))
- put("isClearable", JsonPrimitive(entry.isClearable))
- entry.title?.let { put("title", JsonPrimitive(it)) }
- entry.text?.let { put("text", JsonPrimitive(it)) }
- entry.subText?.let { put("subText", JsonPrimitive(it)) }
- entry.category?.let { put("category", JsonPrimitive(it)) }
- entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
- }.toString(),
- )
+ val payload = notificationChangedPayload(entry) ?: return
+ emitNotificationsChanged(payload)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
@@ -180,21 +175,79 @@ class DeviceNotificationListenerService : NotificationListenerService() {
return
}
DeviceNotificationStore.remove(key)
+ rememberRecentPackage(removed.packageName)
if (removed.packageName == packageName) {
return
}
- emitNotificationsChanged(
- buildJsonObject {
- put("change", JsonPrimitive("removed"))
- put("key", JsonPrimitive(key))
- val packageName = removed.packageName.trim()
- if (packageName.isNotEmpty()) {
- put("packageName", JsonPrimitive(packageName))
- }
- }.toString(),
+ val packageName = removed.packageName.trim()
+ val payload =
+ notificationChangedPayload(
+ entry = null,
+ change = "removed",
+ key = key,
+ packageName = packageName,
+ postTimeMs = removed.postTime,
+ isOngoing = removed.isOngoing,
+ isClearable = removed.isClearable,
+ ) ?: return
+ emitNotificationsChanged(payload)
+ }
+
+ private fun notificationChangedPayload(entry: DeviceNotificationEntry): String? {
+ return notificationChangedPayload(
+ entry = entry,
+ change = "posted",
+ key = entry.key,
+ packageName = entry.packageName,
+ postTimeMs = entry.postTimeMs,
+ isOngoing = entry.isOngoing,
+ isClearable = entry.isClearable,
)
}
+ private fun notificationChangedPayload(
+ entry: DeviceNotificationEntry?,
+ change: String,
+ key: String,
+ packageName: String,
+ postTimeMs: Long,
+ isOngoing: Boolean,
+ isClearable: Boolean,
+ ): String? {
+ val normalizedPackage = packageName.trim()
+ if (normalizedPackage.isEmpty()) {
+ return null
+ }
+ val policy = securePrefs.getNotificationForwardingPolicy(appPackageName = this.packageName)
+ if (!policy.enabled) {
+ return null
+ }
+ if (!policy.allowsPackage(normalizedPackage)) {
+ return null
+ }
+ val nowEpochMs = System.currentTimeMillis()
+ if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
+ return null
+ }
+ if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
+ return null
+ }
+ return buildJsonObject {
+ put("change", JsonPrimitive(change))
+ put("key", JsonPrimitive(key))
+ put("packageName", JsonPrimitive(normalizedPackage))
+ put("postTimeMs", JsonPrimitive(postTimeMs))
+ put("isOngoing", JsonPrimitive(isOngoing))
+ put("isClearable", JsonPrimitive(isClearable))
+ policy.sessionKey?.let { put("sessionKey", JsonPrimitive(it)) }
+ entry?.title?.let { put("title", JsonPrimitive(it)) }
+ entry?.text?.let { put("text", JsonPrimitive(it)) }
+ entry?.subText?.let { put("subText", JsonPrimitive(it)) }
+ entry?.category?.let { put("category", JsonPrimitive(it)) }
+ entry?.channelId?.let { put("channelId", JsonPrimitive(it)) }
+ }.toString()
+ }
+
private fun refreshActiveNotifications() {
val entries =
runCatching {
@@ -228,6 +281,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
companion object {
+ private const val recentPackagesPref = "notifications.forwarding.recentPackages"
+ private const val legacyRecentPackagesPref = "notifications.recentPackages"
+ private const val recentPackagesLimit = 64
@Volatile private var activeService: DeviceNotificationListenerService? = null
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
@@ -239,6 +295,31 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink = sink
}
+ private fun recentPackagesPrefs(context: Context) =
+ context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
+
+ private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
+ val prefs = recentPackagesPrefs(context)
+ val hasNew = prefs.contains(recentPackagesPref)
+ val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
+ if (!hasNew && legacy.isNotEmpty()) {
+ prefs.edit().putString(recentPackagesPref, legacy).remove(legacyRecentPackagesPref).apply()
+ } else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
+ prefs.edit().remove(legacyRecentPackagesPref).apply()
+ }
+ }
+
+ fun recentPackages(context: Context): List {
+ migrateLegacyRecentPackagesIfNeeded(context)
+ val prefs = recentPackagesPrefs(context)
+ val stored = prefs.getString(recentPackagesPref, null).orEmpty()
+ return stored
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .distinct()
+ }
+
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
@@ -276,6 +357,21 @@ class DeviceNotificationListenerService : NotificationListenerService() {
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
}
}
+
+ private fun rememberRecentPackage(packageName: String?) {
+ val service = activeService ?: return
+ val normalized = packageName?.trim().orEmpty()
+ if (normalized.isEmpty() || normalized == service.packageName) return
+ migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
+ val prefs = recentPackagesPrefs(service.applicationContext)
+ val existing = prefs.getString(recentPackagesPref, null).orEmpty()
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() && it != normalized }
+ .take(recentPackagesLimit - 1)
+ val updated = listOf(normalized) + existing
+ prefs.edit().putString(recentPackagesPref, updated.joinToString(",")).apply()
+ }
}
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
index e7ad138dc21..61784ba217a 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
@@ -34,7 +34,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
@@ -54,20 +53,23 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
+import ai.openclaw.app.normalizeLocalHourMinute
+import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.node.DeviceNotificationListenerService
@Composable
@@ -81,6 +83,55 @@ fun SettingsSheet(viewModel: MainViewModel) {
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
+ val notificationForwardingEnabled by viewModel.notificationForwardingEnabled.collectAsState()
+ val notificationForwardingMode by viewModel.notificationForwardingMode.collectAsState()
+ val notificationForwardingPackages by viewModel.notificationForwardingPackages.collectAsState()
+ val notificationForwardingQuietHoursEnabled by viewModel.notificationForwardingQuietHoursEnabled.collectAsState()
+ val notificationForwardingQuietStart by viewModel.notificationForwardingQuietStart.collectAsState()
+ val notificationForwardingQuietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
+ val notificationForwardingMaxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
+ val notificationForwardingSessionKey by viewModel.notificationForwardingSessionKey.collectAsState()
+
+ var notificationQuietStartDraft by remember(notificationForwardingQuietStart) {
+ mutableStateOf(notificationForwardingQuietStart)
+ }
+ var notificationQuietEndDraft by remember(notificationForwardingQuietEnd) {
+ mutableStateOf(notificationForwardingQuietEnd)
+ }
+ var notificationRateDraft by remember(notificationForwardingMaxEventsPerMinute) {
+ mutableStateOf(notificationForwardingMaxEventsPerMinute.toString())
+ }
+ var notificationSessionKeyDraft by remember(notificationForwardingSessionKey) {
+ mutableStateOf(notificationForwardingSessionKey.orEmpty())
+ }
+ val normalizedQuietStartDraft = remember(notificationQuietStartDraft) {
+ normalizeLocalHourMinute(notificationQuietStartDraft)
+ }
+ val normalizedQuietEndDraft = remember(notificationQuietEndDraft) {
+ normalizeLocalHourMinute(notificationQuietEndDraft)
+ }
+ val quietHoursDraftValid = normalizedQuietStartDraft != null && normalizedQuietEndDraft != null
+ val selectedPackagesSummary = remember(notificationForwardingMode, notificationForwardingPackages) {
+ when (notificationForwardingMode) {
+ NotificationPackageFilterMode.Allowlist ->
+ if (notificationForwardingPackages.isEmpty()) {
+ "Selected: none — allowlist mode forwards nothing until you add apps."
+ } else {
+ "Selected: ${notificationForwardingPackages.size} app(s) allowed."
+ }
+ NotificationPackageFilterMode.Blocklist ->
+ if (notificationForwardingPackages.isEmpty()) {
+ "Selected: none — blocklist mode forwards all apps except OpenClaw."
+ } else {
+ "Selected: ${notificationForwardingPackages.size} app(s) blocked."
+ }
+ }
+ }
+ val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
+ val quietHoursDraftDirty =
+ notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
+ notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
+ val quietHoursSaveEnabled = notificationForwardingEnabled && quietHoursDraftValid && quietHoursDraftDirty
val listState = rememberLazyListState()
val deviceModel =
@@ -175,6 +226,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(isNotificationListenerEnabled(context))
}
+ val notificationForwardingAvailable = notificationForwardingEnabled && notificationListenerEnabled
+ val notificationForwardingControlsAlpha = if (notificationForwardingAvailable) 1f else 0.6f
+
+ var notificationPickerExpanded by remember { mutableStateOf(false) }
+ var notificationAppSearch by remember { mutableStateOf("") }
+ var notificationShowSystemApps by remember { mutableStateOf(false) }
+ var installedNotificationApps by
+ remember(context, notificationForwardingPackages) {
+ mutableStateOf(queryInstalledApps(context, notificationForwardingPackages))
+ }
var photosPermissionGranted by
remember {
@@ -271,6 +332,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
notificationsPermissionGranted = hasNotificationsPermission(context)
notificationListenerEnabled = isNotificationListenerEnabled(context)
+ installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
photosPermissionGranted =
ContextCompat.checkSelfPermission(context, photosPermission) ==
PackageManager.PERMISSION_GRANTED
@@ -351,6 +413,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
+ val normalizedAppSearch = notificationAppSearch.trim().lowercase()
+ val filteredNotificationApps =
+ remember(installedNotificationApps, normalizedAppSearch, notificationShowSystemApps) {
+ installedNotificationApps
+ .asSequence()
+ .filter { app -> notificationShowSystemApps || !app.isSystemApp }
+ .filter { app ->
+ normalizedAppSearch.isEmpty() ||
+ app.label.lowercase().contains(normalizedAppSearch) ||
+ app.packageName.lowercase().contains(normalizedAppSearch)
+ }
+ .toList()
+ }
+
Box(
modifier =
Modifier
@@ -491,9 +567,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
- headlineContent = { Text("Notification Listener", style = mobileHeadline) },
+ headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
supportingContent = {
- Text("Read and interact with notifications.", style = mobileCallout)
+ Text(
+ "Required for `notifications.list`, `notifications.actions`, and forwarded notification events.",
+ style = mobileCallout,
+ )
},
trailingContent = {
Button(
@@ -539,6 +618,297 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
}
+ item {
+ ListItem(
+ modifier = Modifier.settingsRowModifier(),
+ colors = listItemColors,
+ headlineContent = { Text("Forward Notification Events", style = mobileHeadline) },
+ supportingContent = {
+ Text(
+ if (notificationListenerEnabled) {
+ "Forward listener events into gateway node events. Off by default until you enable it."
+ } else {
+ "Notification listener access is off, so no notification events can be forwarded yet."
+ },
+ style = mobileCallout,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = notificationForwardingEnabled,
+ onCheckedChange = viewModel::setNotificationForwardingEnabled,
+ enabled = notificationListenerEnabled,
+ )
+ },
+ )
+ }
+ item {
+ Text(
+ if (notificationListenerEnabled) {
+ "Forwarding is available when enabled below."
+ } else {
+ "Forwarding controls stay disabled until Notification Listener Access is enabled in system Settings."
+ },
+ style = mobileCallout,
+ color = mobileTextSecondary,
+ )
+ }
+ item {
+ Column(
+ modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ ListItem(
+ modifier = Modifier.fillMaxWidth(),
+ colors = listItemColors,
+ headlineContent = { Text("Package Filter: Allowlist", style = mobileHeadline) },
+ supportingContent = {
+ Text("Only listed package IDs are forwarded.", style = mobileCallout)
+ },
+ trailingContent = {
+ RadioButton(
+ selected = notificationForwardingMode == NotificationPackageFilterMode.Allowlist,
+ onClick = {
+ viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Allowlist)
+ },
+ enabled = notificationForwardingAvailable,
+ )
+ },
+ )
+ HorizontalDivider(color = mobileBorder)
+ ListItem(
+ modifier = Modifier.fillMaxWidth(),
+ colors = listItemColors,
+ headlineContent = { Text("Package Filter: Blocklist", style = mobileHeadline) },
+ supportingContent = {
+ Text("All packages except listed IDs are forwarded.", style = mobileCallout)
+ },
+ trailingContent = {
+ RadioButton(
+ selected = notificationForwardingMode == NotificationPackageFilterMode.Blocklist,
+ onClick = {
+ viewModel.setNotificationForwardingMode(NotificationPackageFilterMode.Blocklist)
+ },
+ enabled = notificationForwardingAvailable,
+ )
+ },
+ )
+ }
+ }
+ item {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Button(
+ onClick = { notificationPickerExpanded = !notificationPickerExpanded },
+ enabled = notificationForwardingAvailable,
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text(
+ if (notificationPickerExpanded) "Close App Picker" else "Open App Picker",
+ style = mobileCallout.copy(fontWeight = FontWeight.Bold),
+ )
+ }
+ }
+ }
+ item {
+ Text(
+ selectedPackagesSummary,
+ style = mobileCallout,
+ color = mobileTextSecondary,
+ )
+ }
+ if (notificationPickerExpanded) {
+ item {
+ OutlinedTextField(
+ value = notificationAppSearch,
+ onValueChange = { notificationAppSearch = it },
+ label = {
+ Text("Search apps", style = mobileCaption1, color = mobileTextSecondary)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ textStyle = mobileBody.copy(color = mobileText),
+ colors = settingsTextFieldColors(),
+ enabled = notificationForwardingAvailable,
+ )
+ }
+ item {
+ ListItem(
+ modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
+ colors = listItemColors,
+ headlineContent = { Text("Show System Apps", style = mobileHeadline) },
+ supportingContent = {
+ Text("Include Android/system packages in results.", style = mobileCallout)
+ },
+ trailingContent = {
+ Switch(
+ checked = notificationShowSystemApps,
+ onCheckedChange = { notificationShowSystemApps = it },
+ enabled = notificationForwardingAvailable,
+ )
+ },
+ )
+ }
+ items(filteredNotificationApps, key = { it.packageName }) { app ->
+ ListItem(
+ modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
+ colors = listItemColors,
+ headlineContent = { Text(app.label, style = mobileHeadline) },
+ supportingContent = { Text(app.packageName, style = mobileCallout) },
+ trailingContent = {
+ Switch(
+ checked = notificationForwardingPackages.contains(app.packageName),
+ onCheckedChange = { checked ->
+ val next = notificationForwardingPackages.toMutableSet()
+ if (checked) {
+ next.add(app.packageName)
+ } else {
+ next.remove(app.packageName)
+ }
+ viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
+ },
+ enabled = notificationForwardingAvailable,
+ )
+ },
+ )
+ }
+ }
+ item {
+ ListItem(
+ modifier = Modifier.settingsRowModifier().alpha(notificationForwardingControlsAlpha),
+ colors = listItemColors,
+ headlineContent = { Text("Quiet Hours", style = mobileHeadline) },
+ supportingContent = {
+ Text("Suppress forwarding during a local time window.", style = mobileCallout)
+ },
+ trailingContent = {
+ Switch(
+ checked = notificationForwardingQuietHoursEnabled,
+ onCheckedChange = {
+ if (!quietHoursCanEnable && it) return@Switch
+ viewModel.setNotificationForwardingQuietHours(
+ enabled = it,
+ start = notificationQuietStartDraft,
+ end = notificationQuietEndDraft,
+ )
+ },
+ enabled = if (notificationForwardingQuietHoursEnabled) notificationForwardingAvailable else quietHoursCanEnable,
+ )
+ },
+ )
+ }
+ item {
+ OutlinedTextField(
+ value = notificationQuietStartDraft,
+ onValueChange = { notificationQuietStartDraft = it },
+ label = { Text("Quiet Start (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
+ modifier = Modifier.fillMaxWidth(),
+ textStyle = mobileBody.copy(color = mobileText),
+ colors = settingsTextFieldColors(),
+ enabled = notificationForwardingAvailable,
+ isError = notificationForwardingAvailable && normalizedQuietStartDraft == null,
+ supportingText = {
+ if (notificationForwardingAvailable && normalizedQuietStartDraft == null) {
+ Text("Use 24-hour HH:mm format, for example 22:00.", style = mobileCaption1, color = mobileDanger)
+ }
+ },
+ )
+ }
+ item {
+ OutlinedTextField(
+ value = notificationQuietEndDraft,
+ onValueChange = { notificationQuietEndDraft = it },
+ label = { Text("Quiet End (HH:mm)", style = mobileCaption1, color = mobileTextSecondary) },
+ modifier = Modifier.fillMaxWidth(),
+ textStyle = mobileBody.copy(color = mobileText),
+ colors = settingsTextFieldColors(),
+ enabled = notificationForwardingAvailable,
+ isError = notificationForwardingAvailable && normalizedQuietEndDraft == null,
+ supportingText = {
+ if (notificationForwardingAvailable && normalizedQuietEndDraft == null) {
+ Text("Use 24-hour HH:mm format, for example 07:00.", style = mobileCaption1, color = mobileDanger)
+ }
+ },
+ )
+ }
+ item {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Button(
+ onClick = {
+ viewModel.setNotificationForwardingQuietHours(
+ enabled = notificationForwardingQuietHoursEnabled,
+ start = notificationQuietStartDraft,
+ end = notificationQuietEndDraft,
+ )
+ },
+ enabled = quietHoursSaveEnabled,
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text("Save Quiet Hours", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
+ }
+ }
+ }
+ item {
+ OutlinedTextField(
+ value = notificationRateDraft,
+ onValueChange = { notificationRateDraft = it.filter { c -> c.isDigit() } },
+ label = { Text("Max Events / Minute", style = mobileCaption1, color = mobileTextSecondary) },
+ modifier = Modifier.fillMaxWidth(),
+ textStyle = mobileBody.copy(color = mobileText),
+ colors = settingsTextFieldColors(),
+ enabled = notificationForwardingAvailable,
+ )
+ }
+ item {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Button(
+ onClick = {
+ val parsed = notificationRateDraft.toIntOrNull() ?: notificationForwardingMaxEventsPerMinute
+ viewModel.setNotificationForwardingMaxEventsPerMinute(parsed)
+ },
+ enabled = notificationForwardingAvailable,
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text("Save Rate", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
+ }
+ }
+ }
+ item {
+ OutlinedTextField(
+ value = notificationSessionKeyDraft,
+ onValueChange = { notificationSessionKeyDraft = it },
+ label = {
+ Text(
+ "Route Session Key (optional)",
+ style = mobileCaption1,
+ color = mobileTextSecondary,
+ )
+ },
+ placeholder = {
+ Text("Blank keeps notification events on this device's default notification route. Set a key only to pin forwarding into a different session.", style = mobileCaption1, color = mobileTextSecondary)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ textStyle = mobileBody.copy(color = mobileText),
+ colors = settingsTextFieldColors(),
+ enabled = notificationForwardingAvailable,
+ )
+ }
+ item {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ Button(
+ onClick = {
+ viewModel.setNotificationForwardingSessionKey(notificationSessionKeyDraft.trim().ifEmpty { null })
+ },
+ enabled = notificationForwardingAvailable,
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text("Save Session Route", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
+ }
+ }
+ }
+ item { HorizontalDivider(color = mobileBorder) }
// ── Data Access ──
item {
@@ -774,6 +1144,78 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
+data class InstalledApp(
+ val label: String,
+ val packageName: String,
+ val isSystemApp: Boolean,
+)
+
+private fun queryInstalledApps(
+ context: Context,
+ configuredPackages: Set,
+): List {
+ val packageManager = context.packageManager
+ val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
+
+ val launcherPackages =
+ packageManager
+ .queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
+ .asSequence()
+ .mapNotNull { it.activityInfo?.packageName?.trim()?.takeIf(String::isNotEmpty) }
+ .toMutableSet()
+
+ val recentNotificationPackages =
+ DeviceNotificationListenerService
+ .recentPackages(context)
+ .asSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toList()
+
+ val candidatePackages =
+ resolveNotificationCandidatePackages(
+ launcherPackages = launcherPackages,
+ recentPackages = recentNotificationPackages,
+ configuredPackages = configuredPackages,
+ appPackageName = context.packageName,
+ )
+
+ return candidatePackages
+ .asSequence()
+ .mapNotNull { packageName ->
+ runCatching {
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
+ val label = packageManager.getApplicationLabel(appInfo)?.toString()?.trim().orEmpty()
+ InstalledApp(
+ label = if (label.isEmpty()) packageName else label,
+ packageName = packageName,
+ isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
+ )
+ }.getOrNull()
+ }
+ .sortedWith(compareBy { it.label.lowercase() }.thenBy { it.packageName })
+ .toList()
+}
+
+internal fun resolveNotificationCandidatePackages(
+ launcherPackages: Set,
+ recentPackages: List,
+ configuredPackages: Set,
+ appPackageName: String,
+): Set {
+ val blockedPackage = appPackageName.trim()
+ return sequenceOf(
+ configuredPackages.asSequence(),
+ launcherPackages.asSequence(),
+ recentPackages.asSequence(),
+ )
+ .flatten()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() && it != blockedPackage }
+ .toSet()
+}
+
+
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt
new file mode 100644
index 00000000000..7c4fa5a3671
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/NotificationForwardingPolicyTest.kt
@@ -0,0 +1,189 @@
+package ai.openclaw.app
+
+import java.time.LocalDateTime
+import java.time.ZoneId
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NotificationForwardingPolicyTest {
+ @Test
+ fun parseLocalHourMinute_parsesValidValues() {
+ assertEquals(0, parseLocalHourMinute("00:00"))
+ assertEquals(23 * 60 + 59, parseLocalHourMinute("23:59"))
+ assertEquals(7 * 60 + 5, parseLocalHourMinute("07:05"))
+ }
+
+ @Test
+ fun normalizeLocalHourMinute_acceptsStrict24HourDrafts() {
+ assertEquals("00:00", normalizeLocalHourMinute("00:00"))
+ assertEquals("23:59", normalizeLocalHourMinute("23:59"))
+ assertEquals("07:05", normalizeLocalHourMinute("07:05"))
+ }
+
+ @Test
+ fun parseLocalHourMinute_rejectsInvalidValues() {
+ assertEquals(null, parseLocalHourMinute(""))
+ assertEquals(null, parseLocalHourMinute("24:00"))
+ assertEquals(null, parseLocalHourMinute("12:60"))
+ assertEquals(null, parseLocalHourMinute("abc"))
+ assertEquals(null, parseLocalHourMinute("7:05"))
+ assertEquals(null, parseLocalHourMinute("07:5"))
+ }
+
+ @Test
+ fun normalizeLocalHourMinute_rejectsNonCanonicalDrafts() {
+ assertEquals(null, normalizeLocalHourMinute(""))
+ assertEquals(null, normalizeLocalHourMinute("7:05"))
+ assertEquals(null, normalizeLocalHourMinute("07:5"))
+ assertEquals(null, normalizeLocalHourMinute("24:00"))
+ assertEquals(null, normalizeLocalHourMinute("12:60"))
+ }
+
+ @Test
+ fun allowsPackage_blocklistBlocksConfiguredPackages() {
+ val policy =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = setOf("com.blocked.app"),
+ quietHoursEnabled = false,
+ quietStart = "22:00",
+ quietEnd = "07:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+
+ assertFalse(policy.allowsPackage("com.blocked.app"))
+ assertTrue(policy.allowsPackage("com.allowed.app"))
+ }
+
+ @Test
+ fun allowsPackage_allowlistOnlyAllowsConfiguredPackages() {
+ val policy =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Allowlist,
+ packages = setOf("com.allowed.app"),
+ quietHoursEnabled = false,
+ quietStart = "22:00",
+ quietEnd = "07:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+
+ assertTrue(policy.allowsPackage("com.allowed.app"))
+ assertFalse(policy.allowsPackage("com.other.app"))
+ }
+
+ @Test
+ fun isWithinQuietHours_handlesWindowCrossingMidnight() {
+ val policy =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = emptySet(),
+ quietHoursEnabled = true,
+ quietStart = "22:00",
+ quietEnd = "07:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+
+ val zone = ZoneId.of("UTC")
+ val at2330 =
+ LocalDateTime
+ .of(2024, 1, 6, 23, 30)
+ .atZone(zone)
+ .toInstant()
+ .toEpochMilli()
+ val at1200 =
+ LocalDateTime
+ .of(2024, 1, 6, 12, 0)
+ .atZone(zone)
+ .toInstant()
+ .toEpochMilli()
+
+ assertTrue(policy.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
+ assertFalse(policy.isWithinQuietHours(nowEpochMs = at1200, zoneId = zone))
+ }
+
+ @Test
+ fun isWithinQuietHours_sameStartEndMeansAlwaysQuiet() {
+ val policy =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = emptySet(),
+ quietHoursEnabled = true,
+ quietStart = "00:00",
+ quietEnd = "00:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+
+ assertTrue(policy.isWithinQuietHours(nowEpochMs = 1_704_098_400_000L, zoneId = ZoneId.of("UTC")))
+ }
+
+ @Test
+ fun blocksEventsWhenDisabledOrQuietHoursOrRateLimited() {
+ val disabled =
+ NotificationForwardingPolicy(
+ enabled = false,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = emptySet(),
+ quietHoursEnabled = false,
+ quietStart = "22:00",
+ quietEnd = "07:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+ assertFalse(disabled.enabled && disabled.allowsPackage("com.allowed.app"))
+
+ val quiet =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = emptySet(),
+ quietHoursEnabled = true,
+ quietStart = "22:00",
+ quietEnd = "07:00",
+ maxEventsPerMinute = 20,
+ sessionKey = null,
+ )
+ val zone = ZoneId.of("UTC")
+ val at2330 =
+ LocalDateTime
+ .of(2024, 1, 6, 23, 30)
+ .atZone(zone)
+ .toInstant()
+ .toEpochMilli()
+ assertTrue(quiet.isWithinQuietHours(nowEpochMs = at2330, zoneId = zone))
+
+ val limiter = NotificationBurstLimiter()
+ val minute = 1_704_098_400_000L
+ assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
+ assertFalse(limiter.allow(nowEpochMs = minute + 500L, maxEventsPerMinute = 1))
+ }
+
+ @Test
+ fun burstLimiter_blocksEventsAboveLimitInSameMinute() {
+ val limiter = NotificationBurstLimiter()
+ val minute = 1_704_098_400_000L
+
+ assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 2))
+ assertTrue(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 2))
+ assertFalse(limiter.allow(nowEpochMs = minute + 2_000L, maxEventsPerMinute = 2))
+ }
+
+ @Test
+ fun burstLimiter_resetsOnNextMinuteWindow() {
+ val limiter = NotificationBurstLimiter()
+ val minute = 1_704_098_400_000L
+
+ assertTrue(limiter.allow(nowEpochMs = minute, maxEventsPerMinute = 1))
+ assertFalse(limiter.allow(nowEpochMs = minute + 1_000L, maxEventsPerMinute = 1))
+ assertTrue(limiter.allow(nowEpochMs = minute + 60_000L, maxEventsPerMinute = 1))
+ }
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt
new file mode 100644
index 00000000000..30ba72289f8
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsNotificationForwardingTest.kt
@@ -0,0 +1,133 @@
+package ai.openclaw.app
+
+import android.content.Context
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class SecurePrefsNotificationForwardingTest {
+ @Test
+ fun setNotificationForwardingQuietHours_rejectsInvalidDraftsWithoutMutatingStoredValues() {
+ val context = RuntimeEnvironment.getApplication()
+ val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
+ plainPrefs.edit().clear().commit()
+
+ val prefs = SecurePrefs(context)
+
+ assertTrue(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = false,
+ start = "22:00",
+ end = "07:00",
+ ),
+ )
+
+ val originalStart = prefs.notificationForwardingQuietStart.value
+ val originalEnd = prefs.notificationForwardingQuietEnd.value
+ val originalEnabled = prefs.notificationForwardingQuietHoursEnabled.value
+
+ assertFalse(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = true,
+ start = "7:00",
+ end = "07:00",
+ ),
+ )
+
+ assertEquals(originalStart, prefs.notificationForwardingQuietStart.value)
+ assertEquals(originalEnd, prefs.notificationForwardingQuietEnd.value)
+ assertEquals(originalEnabled, prefs.notificationForwardingQuietHoursEnabled.value)
+ }
+
+ @Test
+ fun setNotificationForwardingQuietHours_persistsValidDraftsAndEnabledState() {
+ val context = RuntimeEnvironment.getApplication()
+ val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
+ plainPrefs.edit().clear().commit()
+
+ val prefs = SecurePrefs(context)
+
+ assertTrue(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = true,
+ start = "22:30",
+ end = "06:45",
+ ),
+ )
+
+ assertTrue(prefs.notificationForwardingQuietHoursEnabled.value)
+ assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
+ assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
+ }
+
+ @Test
+ fun setNotificationForwardingQuietHours_disablesWithoutRevalidatingDrafts() {
+ val context = RuntimeEnvironment.getApplication()
+ val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
+ plainPrefs.edit().clear().commit()
+
+ val prefs = SecurePrefs(context)
+ assertTrue(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = true,
+ start = "22:30",
+ end = "06:45",
+ ),
+ )
+
+ assertTrue(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = false,
+ start = "7:00",
+ end = "06:45",
+ ),
+ )
+
+ assertFalse(prefs.notificationForwardingQuietHoursEnabled.value)
+ assertEquals("22:30", prefs.notificationForwardingQuietStart.value)
+ assertEquals("06:45", prefs.notificationForwardingQuietEnd.value)
+ }
+
+
+ @Test
+ fun getNotificationForwardingPolicy_readsLatestQuietHoursImmediately() {
+ val context = RuntimeEnvironment.getApplication()
+ val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
+ plainPrefs.edit().clear().commit()
+
+ val prefs = SecurePrefs(context)
+ assertTrue(
+ prefs.setNotificationForwardingQuietHours(
+ enabled = true,
+ start = "21:15",
+ end = "06:10",
+ ),
+ )
+
+ val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
+
+ assertTrue(policy.quietHoursEnabled)
+ assertEquals("21:15", policy.quietStart)
+ assertEquals("06:10", policy.quietEnd)
+ }
+
+ @Test
+ fun notificationForwarding_defaultsDisabledForSaferPosture() {
+ val context = RuntimeEnvironment.getApplication()
+ val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
+ plainPrefs.edit().clear().commit()
+
+ val prefs = SecurePrefs(context)
+ val policy = prefs.getNotificationForwardingPolicy(appPackageName = "ai.openclaw.app")
+
+ assertFalse(prefs.notificationForwardingEnabled.value)
+ assertFalse(policy.enabled)
+ assertEquals(NotificationPackageFilterMode.Blocklist, policy.mode)
+ }
+
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt
new file mode 100644
index 00000000000..236fded732f
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceNotificationListenerServiceTest.kt
@@ -0,0 +1,119 @@
+package ai.openclaw.app.node
+
+import android.content.Context
+import ai.openclaw.app.NotificationBurstLimiter
+import ai.openclaw.app.NotificationForwardingPolicy
+import ai.openclaw.app.NotificationPackageFilterMode
+import ai.openclaw.app.isWithinQuietHours
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class DeviceNotificationListenerServiceTest {
+ @Test
+ fun recentPackages_migratesLegacyPreferenceKey() {
+ val context = RuntimeEnvironment.getApplication()
+ val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
+ prefs.edit()
+ .clear()
+ .putString("notifications.recentPackages", "com.example.one, com.example.two")
+ .commit()
+
+ val packages = DeviceNotificationListenerService.recentPackages(context)
+
+ assertEquals(listOf("com.example.one", "com.example.two"), packages)
+ assertEquals(
+ "com.example.one, com.example.two",
+ prefs.getString("notifications.forwarding.recentPackages", null),
+ )
+ assertFalse(prefs.contains("notifications.recentPackages"))
+ }
+
+ @Test
+ fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
+ val context = RuntimeEnvironment.getApplication()
+ val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
+ prefs.edit()
+ .clear()
+ .putString("notifications.forwarding.recentPackages", "com.example.new")
+ .putString("notifications.recentPackages", "com.example.legacy")
+ .commit()
+
+ val packages = DeviceNotificationListenerService.recentPackages(context)
+
+ assertEquals(listOf("com.example.new"), packages)
+ assertNull(prefs.getString("notifications.recentPackages", null))
+ }
+
+ @Test
+ fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
+ val context = RuntimeEnvironment.getApplication()
+ val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
+ prefs.edit()
+ .clear()
+ .putString(
+ "notifications.forwarding.recentPackages",
+ " com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
+ )
+ .commit()
+
+ val packages = DeviceNotificationListenerService.recentPackages(context)
+
+ assertEquals(
+ listOf("com.example.recent", "com.example.other", "com.example.third"),
+ packages,
+ )
+ }
+
+ @Test
+ fun quietHoursAndRateLimitingUseWallClockTimeNotNotificationPostTime() {
+ val zone = java.time.ZoneId.systemDefault()
+ val now = java.time.ZonedDateTime.now(zone)
+ val quietStart = now.minusMinutes(5).toLocalTime().withSecond(0).withNano(0)
+ val quietEnd = now.plusMinutes(5).toLocalTime().withSecond(0).withNano(0)
+ val stalePostTime =
+ now
+ .minusHours(2)
+ .withMinute(0)
+ .withSecond(0)
+ .withNano(0)
+ .toInstant()
+ .toEpochMilli()
+
+ val policy =
+ NotificationForwardingPolicy(
+ enabled = true,
+ mode = NotificationPackageFilterMode.Blocklist,
+ packages = emptySet(),
+ quietHoursEnabled = true,
+ quietStart = "%02d:%02d".format(quietStart.hour, quietStart.minute),
+ quietEnd = "%02d:%02d".format(quietEnd.hour, quietEnd.minute),
+ maxEventsPerMinute = 1,
+ sessionKey = null,
+ )
+
+ assertFalse(policy.isWithinQuietHours(nowEpochMs = stalePostTime, zoneId = zone))
+ assertTrue(policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis(), zoneId = zone))
+
+ val limiter = NotificationBurstLimiter()
+ assertTrue(limiter.allow(nowEpochMs = stalePostTime, maxEventsPerMinute = 1))
+ assertTrue(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
+ assertFalse(limiter.allow(nowEpochMs = System.currentTimeMillis(), maxEventsPerMinute = 1))
+ }
+
+ @Test
+ fun burstLimiter_capsAnyForwardedNotificationEvent() {
+ val limiter = NotificationBurstLimiter()
+ val nowEpochMs = System.currentTimeMillis()
+
+ assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
+ assertTrue(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
+ assertFalse(limiter.allow(nowEpochMs = nowEpochMs, maxEventsPerMinute = 2))
+ }
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt
new file mode 100644
index 00000000000..eab1bea2332
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsSheetNotificationAppsTest.kt
@@ -0,0 +1,35 @@
+package ai.openclaw.app.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SettingsSheetNotificationAppsTest {
+ @Test
+ fun resolveNotificationCandidatePackages_keepsConfiguredPackagesVisible() {
+ val packages =
+ resolveNotificationCandidatePackages(
+ launcherPackages = setOf("com.example.launcher"),
+ recentPackages = listOf("com.example.recent", "com.example.launcher"),
+ configuredPackages = setOf("com.example.configured"),
+ appPackageName = "ai.openclaw.app",
+ )
+
+ assertEquals(
+ setOf("com.example.launcher", "com.example.recent", "com.example.configured"),
+ packages,
+ )
+ }
+
+ @Test
+ fun resolveNotificationCandidatePackages_filtersBlankAndSelfPackages() {
+ val packages =
+ resolveNotificationCandidatePackages(
+ launcherPackages = setOf(" ", "ai.openclaw.app"),
+ recentPackages = listOf("com.example.recent", " "),
+ configuredPackages = setOf("ai.openclaw.app", "com.example.configured"),
+ appPackageName = "ai.openclaw.app",
+ )
+
+ assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
+ }
+}
diff --git a/test-fixtures/talk-config-contract.json b/test-fixtures/talk-config-contract.json
index 9b34d3cc60e..1d1d21a5b49 100644
--- a/test-fixtures/talk-config-contract.json
+++ b/test-fixtures/talk-config-contract.json
@@ -8,25 +8,25 @@
"provider": "elevenlabs",
"normalizedPayload": true,
"voiceId": "voice-resolved",
- "apiKey": "resolved-key"
+ "apiKey": "xxxxx"
},
"talk": {
"resolved": {
"provider": "elevenlabs",
"config": {
"voiceId": "voice-resolved",
- "apiKey": "resolved-key"
+ "apiKey": "xxxxx"
}
},
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized",
- "apiKey": "normalized-key"
+ "apiKey": "xxxxx"
}
},
"voiceId": "voice-legacy",
- "apiKey": "legacy-key"
+ "apiKey": "xxxxx"
}
},
{