From 5e0662a23303f7ada01495351ba8fc0f01e86df4 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 16 Aug 2026 03:59:32 +0000 Subject: [PATCH] Add custom alert rule engine --- app/build.gradle.kts | 1 + .../notificationsmaster/alerts/AlertModels.kt | 129 ++++++++++++++++++ .../alerts/AlertRuleEvaluator.kt | 29 ++++ .../alerts/AlertRuleEvaluatorTest.kt | 99 ++++++++++++++ gradle/libs.versions.toml | 2 + 5 files changed, 260 insertions(+) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluator.kt create mode 100644 app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluatorTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a00114a..92fe892 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(libs.recyclerview) implementation(libs.swiperefreshlayout) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.re2j) // Present only when the user enables this APK as an LSPosed module. compileOnly(libs.libxposed.api) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt new file mode 100644 index 0000000..7aca802 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt @@ -0,0 +1,129 @@ +package se.ajpanton.notificationsmaster.alerts + +import com.google.re2j.Pattern + +enum class AlertSource { + NOTIFICATION_POST, + NOTIFICATION_UPDATE, + DIRECT_NOTIFICATION_SOUND, + DIRECT_NOTIFICATION_VIBRATION, + ; + + val isDirect get() = this == DIRECT_NOTIFICATION_SOUND || this == DIRECT_NOTIFICATION_VIBRATION +} + +enum class AlertOutcome { PLAY_PROFILE, SILENCE_ORIGINAL, PASS_THROUGH } + +enum class AlertTextField { ANY_TEXT, TITLE, BODY, SENDER } + +enum class AlertTextMode { ANY, CONTAINS, REGEX } + +data class AlertProfile( + val id: String, + val name: String, + val soundUri: String? = null, + val vibrationPattern: List = emptyList(), +) { + init { + require(id.isNotBlank() && name.isNotBlank()) + require(soundUri?.isNotBlank() != false) + require(soundUri != null || vibrationPattern.any { it > 0 }) + require(vibrationPattern.all { it >= 0 }) + } +} + +data class AlertTextMatcher( + val field: AlertTextField = AlertTextField.ANY_TEXT, + val mode: AlertTextMode = AlertTextMode.ANY, + val value: String = "", + val caseSensitive: Boolean = false, +) { + private val pattern = if (mode == AlertTextMode.REGEX) Pattern.compile( + value, + if (caseSensitive) 0 else Pattern.CASE_INSENSITIVE, + ) else null + + init { + require(mode == AlertTextMode.ANY || value.isNotBlank()) + } + + fun matches(event: AlertEvent): Boolean = when (mode) { + AlertTextMode.ANY -> true + AlertTextMode.CONTAINS -> text(event)?.contains(value, ignoreCase = !caseSensitive) == true + AlertTextMode.REGEX -> text(event)?.let { pattern!!.matcher(it).find() } == true + } + + private fun text(event: AlertEvent): String? = when (field) { + AlertTextField.TITLE -> event.title + AlertTextField.BODY -> event.body + AlertTextField.SENDER -> event.sender + AlertTextField.ANY_TEXT -> listOfNotNull(event.title, event.body, event.sender).joinToString("\n").ifBlank { null } + } +} + +data class AlertRule( + val id: String, + val packageName: String, + val order: Int, + val sources: Set, + val matcher: AlertTextMatcher = AlertTextMatcher(), + val outcome: AlertOutcome, + val profileId: String? = null, + val playToCompletion: Boolean = false, + val allowDuringDnd: Boolean = false, + val enabled: Boolean = true, + val name: String? = null, +) { + init { + require(id.isNotBlank() && packageName.isNotBlank() && sources.isNotEmpty()) + require((outcome == AlertOutcome.PLAY_PROFILE) == (profileId != null)) + require(name?.isNotBlank() != false) + } + + fun groupingKey() = AlertRuleGroupingKey( + sources = sources, + matcher = matcher, + outcome = outcome, + profileId = profileId, + playToCompletion = playToCompletion, + allowDuringDnd = allowDuringDnd, + enabled = enabled, + ) +} + +data class AlertRuleGroupingKey( + val sources: Set, + val matcher: AlertTextMatcher, + val outcome: AlertOutcome, + val profileId: String?, + val playToCompletion: Boolean, + val allowDuringDnd: Boolean, + val enabled: Boolean, +) + +data class AlertAppSettings( + val packageName: String, + val allowMultipleMatches: Boolean = false, + val directAlertControl: Boolean = false, +) + +data class AlertEvent( + val packageName: String, + val source: AlertSource, + val title: String? = null, + val body: String? = null, + val sender: String? = null, +) + +data class AlertDecision( + val ruleId: String, + val outcome: AlertOutcome, + val profile: AlertProfile? = null, + val playToCompletion: Boolean, + val allowDuringDnd: Boolean, +) + +data class AlertEvaluation( + val decisions: List, + val silenceUnmatchedDirectAlert: Boolean, +) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluator.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluator.kt new file mode 100644 index 0000000..0fe44ae --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluator.kt @@ -0,0 +1,29 @@ +package se.ajpanton.notificationsmaster.alerts + +object AlertRuleEvaluator { + fun evaluate( + app: AlertAppSettings, + rules: Iterable, + profiles: Map, + event: AlertEvent, + ): AlertEvaluation { + if (app.packageName != event.packageName || event.source.isDirect && !app.directAlertControl) { + return AlertEvaluation(emptyList(), false) + } + val matches = rules.asSequence() + .filter { it.enabled && it.packageName == event.packageName && event.source in it.sources } + .sortedWith(compareBy { it.order }.thenBy { it.id }) + .filter { it.matcher.matches(event) } + .mapNotNull { rule -> rule.decision(profiles[rule.profileId]) } + .let { if (app.allowMultipleMatches) it else it.take(1) } + .toList() + return AlertEvaluation(matches, event.source.isDirect && matches.isEmpty()) + } + + private fun AlertRule.decision(profile: AlertProfile?): AlertDecision? = when (outcome) { + AlertOutcome.PLAY_PROFILE -> profile?.let { + AlertDecision(id, outcome, it, playToCompletion, allowDuringDnd) + } + else -> AlertDecision(id, outcome, null, playToCompletion, allowDuringDnd) + } +} diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluatorTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluatorTest.kt new file mode 100644 index 0000000..61a3941 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEvaluatorTest.kt @@ -0,0 +1,99 @@ +package se.ajpanton.notificationsmaster.alerts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlertRuleEvaluatorTest { + private val app = AlertAppSettings("chat.app") + private val profile = AlertProfile("urgent", "Urgent", soundUri = "content://sound") + + @Test fun `first matching app rule wins by drag order`() { + val first = rule("first", 0, AlertTextMatcher(mode = AlertTextMode.CONTAINS, value = "hello")) + val second = rule("second", 1) + + val result = evaluate(listOf(second, first), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "hello")) + + assertEquals(listOf("first"), result.decisions.map { it.ruleId }) + } + + @Test fun `multi match app setting preserves priority order`() { + val app = app.copy(allowMultipleMatches = true) + val first = rule("first", 0) + val second = rule("second", 1, outcome = AlertOutcome.SILENCE_ORIGINAL) + + val result = AlertRuleEvaluator.evaluate(app, listOf(second, first), mapOf(profile.id to profile), event()) + + assertEquals(listOf("first", "second"), result.decisions.map { it.ruleId }) + } + + @Test fun `direct notification alert is silent by default for opted in app`() { + val app = app.copy(directAlertControl = true) + val result = AlertRuleEvaluator.evaluate( + app, + emptyList(), + emptyMap(), + AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND), + ) + + assertTrue(result.decisions.isEmpty()) + assertTrue(result.silenceUnmatchedDirectAlert) + } + + @Test fun `direct notification alert is untouched without opt in`() { + val result = AlertRuleEvaluator.evaluate( + app, + emptyList(), + emptyMap(), + AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND), + ) + + assertFalse(result.silenceUnmatchedDirectAlert) + } + + @Test fun `text and regex match only notification contents`() { + val rule = rule("match", 0, AlertTextMatcher(AlertTextField.BODY, AlertTextMode.REGEX, "invoice\\s+#\\d+")) + + val match = evaluate(listOf(rule), AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "Invoice #42")) + val direct = AlertRuleEvaluator.evaluate( + app.copy(directAlertControl = true), + listOf(rule.copy(sources = setOf(AlertSource.DIRECT_NOTIFICATION_SOUND))), + mapOf(profile.id to profile), + AlertEvent("chat.app", AlertSource.DIRECT_NOTIFICATION_SOUND), + ) + + assertEquals(listOf("match"), match.decisions.map { it.ruleId }) + assertTrue(direct.decisions.isEmpty()) + assertTrue(direct.silenceUnmatchedDirectAlert) + } + + @Test fun `grouping ignores app assignment and local order`() { + val original = rule("one", 0, name = "Work") + val sameBehaviorElsewhere = original.copy(id = "two", packageName = "mail.app", order = 7, name = "Mail") + + assertEquals(original.groupingKey(), sameBehaviorElsewhere.groupingKey()) + } + + private fun rule( + id: String, + order: Int, + matcher: AlertTextMatcher = AlertTextMatcher(), + outcome: AlertOutcome = AlertOutcome.PLAY_PROFILE, + name: String? = null, + ) = AlertRule( + id = id, + packageName = "chat.app", + order = order, + sources = setOf(AlertSource.NOTIFICATION_POST), + matcher = matcher, + outcome = outcome, + profileId = if (outcome == AlertOutcome.PLAY_PROFILE) profile.id else null, + name = name, + ) + + private fun event() = AlertEvent("chat.app", AlertSource.NOTIFICATION_POST, body = "hello") + + private fun evaluate(rules: List, event: AlertEvent): AlertEvaluation = + AlertRuleEvaluator.evaluate(app, rules, mapOf(profile.id to profile), event) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b519e1..5610e73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ swipeRefreshLayout = "1.1.0" lifecycle = "2.8.7" junit = "4.13.2" libxposed = "101.0.1" +re2j = "1.8" [libraries] appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } @@ -23,6 +24,7 @@ swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefre lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } junit = { group = "junit", name = "junit", version.ref = "junit" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "libxposed" } +re2j = { group = "com.google.re2j", name = "re2j", version.ref = "re2j" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }