From 7166cfe18bc2618186d5a09280236b177c7dcbf9 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 18 Aug 2026 03:22:03 +0000 Subject: [PATCH] Add privileged alert policy endpoint --- app/src/main/AndroidManifest.xml | 4 ++ .../alerts/AlertNotificationDispatcher.kt | 16 +---- .../alerts/AlertPolicyEvaluator.kt | 21 ++++++ .../module/AlertPolicyService.kt | 64 +++++++++++++++++++ .../alerts/AlertPolicyEvaluatorTest.kt | 20 ++++++ 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluator.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt create mode 100644 app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluatorTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1efa01b..7db2177 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,10 @@ + + diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertNotificationDispatcher.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertNotificationDispatcher.kt index 7757589..b8e0753 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertNotificationDispatcher.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertNotificationDispatcher.kt @@ -11,21 +11,7 @@ object AlertNotificationDispatcher { event: AlertEvent, isRoutineUpdate: Boolean, ): List { - 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 -> + return AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate).decisions.mapNotNull { decision -> decision.snapshot()?.let { QueuedAlert(decision.ruleId, it) } } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluator.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluator.kt new file mode 100644 index 0000000..1196472 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluator.kt @@ -0,0 +1,21 @@ +package se.ajpanton.notificationsmaster.alerts + +/** Evaluates saved alert policy without deciding how a caller will play it. */ +object AlertPolicyEvaluator { + fun evaluate( + configuration: AlertConfiguration, + event: AlertEvent, + isRoutineUpdate: Boolean, + ): AlertEvaluation { + if (!configuration.enabled || + event.source == AlertSource.NOTIFICATION_UPDATE && + isRoutineUpdate && + configuration.ignoreRoutineUpdates + ) { + return AlertEvaluation(emptyList(), false) + } + val app = configuration.appSettings.firstOrNull { it.packageName == event.packageName } + ?: AlertAppSettings(event.packageName) + return AlertRuleEvaluator.evaluate(app, configuration.rules, configuration.profiles.associateBy { it.id }, event) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt new file mode 100644 index 0000000..26056ba --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicyService.kt @@ -0,0 +1,64 @@ +package se.ajpanton.notificationsmaster.module + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore +import se.ajpanton.notificationsmaster.alerts.AlertEvent +import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator +import se.ajpanton.notificationsmaster.alerts.AlertSource +import se.ajpanton.notificationsmaster.alerts.snapshot + +/** + * A deliberately narrow endpoint for the optional system-server module. + * It is exported only so system_server can bind. A bind itself is not a + * sensitive operation; every transaction is authenticated before encrypted + * app data is read. + */ +class AlertPolicyService : Service() { + override fun onBind(intent: Intent): IBinder = PolicyBinder(applicationContext) + + private class PolicyBinder(context: android.content.Context) : Binder() { + private val configurationStore = AlertConfigurationStore(context) + + init { attachInterface(null, DESCRIPTOR) } + + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (Binder.getCallingUid() != android.os.Process.SYSTEM_UID) return false + if (code == INTERFACE_TRANSACTION) { + reply?.writeString(DESCRIPTOR) + return true + } + if (code != TRANSACTION_EVALUATE || reply == null) return false + data.enforceInterface(DESCRIPTOR) + val packageName = data.readString() ?: return false + val source = runCatching { AlertSource.valueOf(data.readString() ?: return false) }.getOrNull() ?: return false + val event = AlertEvent(packageName, source, data.readString(), data.readString(), data.readString()) + val routineUpdate = data.readBoolean() + val result = AlertPolicyEvaluator.evaluate(configurationStore.load(), event, routineUpdate) + reply.writeNoException() + reply.writeBoolean(result.silenceUnmatchedDirectAlert) + reply.writeInt(result.decisions.size) + result.decisions.forEach { decision -> + reply.writeString(decision.ruleId) + reply.writeString(decision.outcome.name) + val snapshot = decision.snapshot() + reply.writeBoolean(snapshot != null) + snapshot?.let { + reply.writeString(it.soundUri) + reply.writeLongArray(it.vibrationPattern.toLongArray()) + reply.writeBoolean(it.playToCompletion) + reply.writeBoolean(it.allowDuringDnd) + } + } + return true + } + } + + companion object { + const val DESCRIPTOR = "se.ajpanton.notificationsmaster.AlertPolicy" + const val TRANSACTION_EVALUATE = IBinder.FIRST_CALL_TRANSACTION + } +} diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluatorTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluatorTest.kt new file mode 100644 index 0000000..b303803 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertPolicyEvaluatorTest.kt @@ -0,0 +1,20 @@ +package se.ajpanton.notificationsmaster.alerts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlertPolicyEvaluatorTest { + private val profile = AlertProfile("profile", "Profile", vibrationPattern = listOf(0, 10)) + private val rule = AlertRule("rule", "chat.app", 0, setOf(AlertSource.NOTIFICATION_POST), outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id) + + @Test fun `returns no decision when alerting is disabled`() { + assertTrue(AlertPolicyEvaluator.evaluate(AlertConfiguration(enabled = false, profiles = listOf(profile), rules = listOf(rule)), event(), false).decisions.isEmpty()) + } + + @Test fun `returns the evaluated decision for a post`() { + assertEquals(listOf("rule"), AlertPolicyEvaluator.evaluate(AlertConfiguration(profiles = listOf(profile), rules = listOf(rule)), event(), false).decisions.map { it.ruleId }) + } + + private fun event() = AlertEvent("chat.app", AlertSource.NOTIFICATION_POST) +}