Route notification alerts through playback

This commit is contained in:
ajp_anton
2026-08-18 01:32:56 +00:00
parent 0089f839e3
commit 6aab879dbd
14 changed files with 251 additions and 32 deletions
@@ -0,0 +1,50 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.ComponentName
import android.content.pm.PackageManager
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Test
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.settings.LoggingRule
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
class AlertListenerPolicyDeviceTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val component = ComponentName(context, NotificationCaptureService::class.java)
@After
fun restoreDefaults() {
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) }
AlertConfigurationStore(context).save(AlertConfiguration())
}
@Test
fun alertRuleKeepsListenerEnabledWithoutLogging() {
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule(enabled = false)) }
val profile = AlertProfile("profile", "Profile", vibrationPattern = listOf(0, 10))
val configuration = AlertConfiguration(
profiles = listOf(profile),
rules = listOf(
AlertRule(
"rule", "example.app", 0, setOf(AlertSource.NOTIFICATION_POST),
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
),
),
)
AlertConfigurationStore(context).save(configuration)
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
context.packageManager.getComponentEnabledSetting(component),
)
AlertConfigurationStore(context).save(AlertConfiguration())
assertEquals(
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
context.packageManager.getComponentEnabledSetting(component),
)
}
}
@@ -4,8 +4,6 @@ import android.app.Application
import android.content.pm.PackageManager
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
import se.ajpanton.notificationsmaster.settings.LoggingType
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
@@ -22,12 +20,7 @@ class NotificationLogApplication : Application() {
* Reconcile it before the UI starts so fresh default rules can bind again.
*/
private fun synchronizeListenerComponent() {
val rules = LoggingRuleStore(this)
NotificationListenerComponentController.update(
this,
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(this).hasEnabledEventOverride(),
)
NotificationListenerComponentController.synchronize(this)
}
private fun removeStaleAppStorage() {
@@ -7,6 +7,7 @@ import org.json.JSONObject
import se.ajpanton.notificationsmaster.data.AesGcmCipher
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
@@ -16,7 +17,8 @@ import java.io.FileNotFoundException
/** Atomic, encrypted storage for alert settings. An unsupported format fails closed. */
class AlertConfigurationStore(context: Context) {
private val file = AtomicFile(File(context.applicationContext.filesDir, FILE_NAME))
private val context = context.applicationContext
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
@Synchronized
@@ -44,6 +46,7 @@ class AlertConfigurationStore(context: Context) {
stream.write(payload.cipherText)
stream.flush()
file.finishWrite(output)
NotificationListenerComponentController.synchronize(context)
} catch (error: Exception) {
file.failWrite(output)
throw error
@@ -59,6 +62,8 @@ class AlertConfigurationStore(context: Context) {
internal object AlertConfigurationJson {
fun encode(configuration: AlertConfiguration): ByteArray = JSONObject()
.put("version", 1)
.put("enabled", configuration.enabled)
.put("ignoreRoutineUpdates", configuration.ignoreRoutineUpdates)
.put("profiles", JSONArray().apply { configuration.profiles.forEach { put(it.toJson()) } })
.put("apps", JSONArray().apply { configuration.appSettings.forEach { put(it.toJson()) } })
.put("rules", JSONArray().apply { configuration.rules.forEach { put(it.toJson()) } })
@@ -70,6 +75,8 @@ internal object AlertConfigurationJson {
val root = JSONObject(bytes.decodeToString())
require(root.getInt("version") == 1) { "Unsupported alert-configuration format." }
return AlertConfiguration(
enabled = root.getBoolean("enabled"),
ignoreRoutineUpdates = root.getBoolean("ignoreRoutineUpdates"),
profiles = root.getJSONArray("profiles").map { (it as JSONObject).toProfile() },
appSettings = root.getJSONArray("apps").map { (it as JSONObject).toAppSettings() },
rules = root.getJSONArray("rules").map { (it as JSONObject).toRule() },
@@ -119,6 +119,8 @@ data class AlertQueueSettings(
}
data class AlertConfiguration(
val enabled: Boolean = true,
val ignoreRoutineUpdates: Boolean = true,
val profiles: List<AlertProfile> = emptyList(),
val appSettings: List<AlertAppSettings> = emptyList(),
val rules: List<AlertRule> = emptyList(),
@@ -132,6 +134,8 @@ data class AlertConfiguration(
rule.profileId in profiles.map { it.id }
})
}
val hasEnabledRules get() = enabled && rules.any { it.enabled }
}
data class AlertEvent(
@@ -0,0 +1,32 @@
package se.ajpanton.notificationsmaster.alerts
/**
* Converts a captured notification event into immutable playback work.
* Rootless operation can play matched profiles but intentionally cannot
* suppress the originating app's native effect.
*/
object AlertNotificationDispatcher {
fun dispatch(
configuration: AlertConfiguration,
event: AlertEvent,
isRoutineUpdate: Boolean,
): List<QueuedAlert> {
if (!configuration.enabled ||
event.source == AlertSource.NOTIFICATION_UPDATE &&
isRoutineUpdate &&
configuration.ignoreRoutineUpdates
) {
return emptyList()
}
val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName }
?: AlertAppSettings(event.packageName)
return AlertRuleEvaluator.evaluate(
app,
configuration.rules,
configuration.profiles.associateBy { it.id },
event,
).decisions.mapNotNull { decision ->
decision.snapshot()?.let { QueuedAlert(decision.ruleId, it) }
}
}
}
@@ -5,6 +5,13 @@ import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertNotificationDispatcher
import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.model.NotificationAction
@@ -27,6 +34,9 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore
private lateinit var alertConfigurationStore: AlertConfigurationStore
private lateinit var alertPlayback: AlertPlaybackController
private var alertQueueSettings = AlertQueueSettings()
override fun onCreate() {
super.onCreate()
@@ -36,6 +46,8 @@ class NotificationCaptureService : NotificationListenerService() {
appFilterStore = AppFilterSettingsStore(this)
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this)
alertConfigurationStore = AlertConfigurationStore(this)
alertPlayback = AlertPlaybackController(AndroidAlertEffectPlayer(this)) { alertQueueSettings }
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
}
@@ -62,23 +74,29 @@ class NotificationCaptureService : NotificationListenerService() {
SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
when {
previous == null -> record(
snapshot,
NotificationAction.APPEARED,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
previous == null -> {
record(
snapshot,
NotificationAction.EDITED,
LoggingType.EDITS,
NotificationAction.APPEARED,
LoggingType.APPEARING,
includeContents = true,
previousSnapshot = previous,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
dispatchAlert(snapshot, AlertSource.NOTIFICATION_POST)
}
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) -> {
if (!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates)) {
record(
snapshot,
NotificationAction.EDITED,
LoggingType.EDITS,
includeContents = true,
previousSnapshot = previous,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
dispatchAlert(snapshot, AlertSource.NOTIFICATION_UPDATE)
}
}
}
@@ -93,6 +111,7 @@ class NotificationCaptureService : NotificationListenerService() {
}
override fun onDestroy() {
alertPlayback.stop()
writeExecutor.shutdown()
super.onDestroy()
}
@@ -160,6 +179,16 @@ class NotificationCaptureService : NotificationListenerService() {
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
}
private fun dispatchAlert(snapshot: NotificationSnapshot, source: AlertSource) {
val configuration = alertConfigurationStore.load()
alertQueueSettings = configuration.queueSettings
AlertNotificationDispatcher.dispatch(
configuration,
AlertEvent(snapshot.packageName, source, snapshot.alertTitle, snapshot.alertBody, snapshot.alertSender),
isRoutineUpdate = source == AlertSource.NOTIFICATION_UPDATE && snapshot.isRoutine,
).forEach(alertPlayback::enqueue)
}
private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
@@ -19,9 +19,18 @@ data class NotificationSnapshot(
val hasImage: Boolean,
val isGroupSummary: Boolean,
val isRoutine: Boolean,
val alertTitle: String? = null,
val alertBody: String? = null,
val alertSender: String? = null,
)
object NotificationContents {
data class AlertTextContents(
val title: String?,
val body: String?,
val sender: String?,
)
fun extract(notification: Notification): String? {
val extras = notification.extras ?: Bundle.EMPTY
messagingContents(extras)?.let { return it }
@@ -69,6 +78,7 @@ object NotificationContents {
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
val notification = sbn.notification
val extras = notification.extras
val alertContents = alertContents(notification)
return NotificationSnapshot(
key = sbn.key,
packageName = sbn.packageName,
@@ -83,9 +93,24 @@ object NotificationContents {
it.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 &&
it.getInt(Notification.EXTRA_PROGRESS, -1) >= 0
} == true,
alertTitle = alertContents.title,
alertBody = alertContents.body,
alertSender = alertContents.sender,
)
}
fun alertContents(notification: Notification): AlertTextContents {
val extras = notification.extras ?: Bundle.EMPTY
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val body = extract(notification)?.let { contents ->
title?.let { contents.removePrefix(it + "\n") }?.ifBlank { null } ?: contents
}
val sender = Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
).lastOrNull()?.senderPerson?.name?.toString()
return AlertTextContents(title, body, sender)
}
/** Called only after the event has passed all capture filters. */
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
val extras = notification.extras ?: return null
@@ -28,11 +28,7 @@ class LoggingRuleStore(context: Context) {
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() {
NotificationListenerComponentController.update(
appContext,
LoggingType.entries.associateWith(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
)
NotificationListenerComponentController.synchronize(appContext)
}
private companion object {
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
/**
@@ -13,11 +14,27 @@ import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
* is therefore both the no-work battery mode and the no-start-on-boot mode.
*/
internal object NotificationListenerComponentController {
fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
fun synchronize(context: Context) {
val applicationContext = context.applicationContext
val rules = LoggingRuleStore(applicationContext)
update(
applicationContext,
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
AlertConfigurationStore(applicationContext).load().hasEnabledRules,
)
}
fun update(
context: Context,
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
hasEnabledAlertRule: Boolean = false,
) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride, hasEnabledAlertRule)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
@@ -5,5 +5,8 @@ object NotificationListenerPolicy {
fun shouldRun(
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
hasEnabledAlertRule: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
hasEnabledEventOverride ||
hasEnabledAlertRule
}
@@ -52,8 +52,7 @@ class PerAppEventSettingsStore(context: Context) {
}
private fun updateListenerComponent() {
val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) }
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
NotificationListenerComponentController.synchronize(appContext)
}
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
@@ -0,0 +1,59 @@
package se.ajpanton.notificationsmaster.alerts
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AlertNotificationDispatcherTest {
private val profile = AlertProfile("profile", "Profile", "content://sound")
private val event = AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "hello")
private val rule = AlertRule(
"rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST),
outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id,
)
@Test
fun disabledAlertingProducesNoPlayback() {
assertTrue(AlertNotificationDispatcher.dispatch(configuration(enabled = false), event, false).isEmpty())
}
@Test
fun matchedPostProducesSnapshotPlayback() {
val queued = AlertNotificationDispatcher.dispatch(configuration(), event, false)
assertEquals(listOf("rule"), queued.map { it.ruleId })
assertEquals(profile.soundUri, queued.single().snapshot.soundUri)
}
@Test
fun routineUpdateIsIgnoredByDefault() {
val updateRule = rule.copy(sources = setOf(AlertSource.NOTIFICATION_UPDATE))
val configuration = AlertConfiguration(profiles = listOf(profile), rules = listOf(updateRule))
assertTrue(
AlertNotificationDispatcher.dispatch(
configuration,
event.copy(source = AlertSource.NOTIFICATION_UPDATE),
isRoutineUpdate = true,
).isEmpty(),
)
}
@Test
fun appCanQueueEveryMatchingRule() {
val second = rule.copy(id = "second", order = 1)
val configuration = AlertConfiguration(
profiles = listOf(profile),
appSettings = listOf(AlertAppSettings("chat.app", allowMultipleMatches = true)),
rules = listOf(rule, second),
)
assertEquals(listOf("rule", "second"), AlertNotificationDispatcher.dispatch(configuration, event, false).map { it.ruleId })
}
private fun configuration(enabled: Boolean = true) = AlertConfiguration(
enabled = enabled,
profiles = listOf(profile),
rules = listOf(rule),
)
}
@@ -59,7 +59,7 @@ class AlertRuleEditorTest {
id, packageName, order, definition.sources, definition.matcher, definition.outcome, definition.profileId,
)
private fun configuration(vararg rules: AlertRule) = AlertConfiguration(listOf(profile), rules = rules.toList())
private fun configuration(vararg rules: AlertRule) = AlertConfiguration(profiles = listOf(profile), rules = rules.toList())
private fun iterOf(vararg values: String) = values.iterator()
}
@@ -26,6 +26,11 @@ class NotificationListenerPolicyTest {
)
}
@Test
fun listenerRunsForEnabledAlertRuleEvenWhenLoggingIsOff() {
assertTrue(NotificationListenerPolicy.shouldRun(rules(), hasEnabledAlertRule = true))
}
private fun rules(vararg enabled: Pair<LoggingType, Boolean>) = LoggingType.entries.associateWith { type ->
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
}