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