fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)

* android(gateway): always send string payloadJSON for node.event

(cherry picked from commit 5ca5a0663ad8fbd9f9f654c52a72b423e1e19605)
(cherry picked from commit bce87e7493e52b0e5959548b410500db8d545a50)

* android: restore notification forwarding controls

(cherry picked from commit 98c4486f83d165919d7f8f1d713ff79ec8126ce7)

* android: restore notification picker discovery UX

(cherry picked from commit 276fbe3f80e036b666070a636c89ee073cdaa934)

* android: enforce notification forwarding policy in listener

(cherry picked from commit 502fb761e05ff911ebde2771eebb1e175ec4dbeb)

* fix(android): harden notification quiet hours inputs

(cherry picked from commit 717d5016f52e98601e6b6d678c991c78fa5ca429)

* fix(android): polish notification forwarding settings

(cherry picked from commit ad667899dea45af70fabfd43f43fa38024def23f)

* test: normalize talk config fixture API key placeholders

* fix(android): use persisted recent packages and wall-clock policy time

* fix(android): keep notification forwarding settings editable

* fix: finalize android notification forwarding controls (#40175) (thanks @nimbleenigma)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
nimbleenigma 2026-03-30 00:30:00 -04:00 committed by GitHub
parent 6af52b4ce3
commit aee61dcee0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1430 additions and 40 deletions

View File

@ -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

View File

@ -31,6 +31,13 @@
android:name="android.hardware.telephony"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:name=".NodeApp"
android:allowBackup="true"

View File

@ -56,6 +56,17 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = 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)
}

View File

@ -534,6 +534,17 @@ class NodeRuntime(
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
prefs.notificationForwardingMode
val notificationForwardingPackages: StateFlow<Set<String>> = prefs.notificationForwardingPackages
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> =
prefs.notificationForwardingQuietHoursEnabled
val notificationForwardingQuietStart: StateFlow<String> = prefs.notificationForwardingQuietStart
val notificationForwardingQuietEnd: StateFlow<String> = prefs.notificationForwardingQuietEnd
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> =
prefs.notificationForwardingMaxEventsPerMinute
val notificationForwardingSessionKey: StateFlow<String?> = 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<String>) {
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()

View File

@ -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<String>,
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
}
}
}

View File

@ -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<Boolean> = _canvasDebugStatusEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
private val _notificationForwardingMode =
MutableStateFlow(
NotificationPackageFilterMode.fromRawValue(
plainPrefs.getString(notificationsForwardingModeKey, null),
),
)
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
val notificationForwardingPackages: StateFlow<Set<String>> = _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<Boolean> = _notificationForwardingQuietHoursEnabled
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
private val _notificationForwardingMaxEventsPerMinute =
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
private val _notificationForwardingSessionKey =
MutableStateFlow(
plainPrefs
.getString(notificationsForwardingSessionKeyKey, "")
?.trim()
?.takeIf { it.isNotEmpty() },
)
val notificationForwardingSessionKey: StateFlow<String?> = _notificationForwardingSessionKey
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _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<String>) {
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<String> {
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)

View File

@ -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)

View File

@ -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<String> {
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 {

View File

@ -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<String>,
): List<InstalledApp> {
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<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
configuredPackages: Set<String>,
appPackageName: String,
): Set<String> {
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(

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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"
}
},
{