Add custom alert rule engine

This commit is contained in:
ajp_anton
2026-08-16 03:59:32 +00:00
parent 9c9ddb6350
commit 5e0662a233
5 changed files with 260 additions and 0 deletions
+1
View File
@@ -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)
@@ -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<Long> = 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<AlertSource>,
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<AlertSource>,
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<AlertDecision>,
val silenceUnmatchedDirectAlert: Boolean,
)
@@ -0,0 +1,29 @@
package se.ajpanton.notificationsmaster.alerts
object AlertRuleEvaluator {
fun evaluate(
app: AlertAppSettings,
rules: Iterable<AlertRule>,
profiles: Map<String, AlertProfile>,
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<AlertRule> { 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)
}
}
@@ -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<AlertRule>, event: AlertEvent): AlertEvaluation =
AlertRuleEvaluator.evaluate(app, rules, mapOf(profile.id to profile), event)
}