Add custom alert rule engine
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user