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