From 594931e51d5a89e4ef598cd6241b77b462448318 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 16 Aug 2026 17:18:43 +0000 Subject: [PATCH] Add consolidated alert rule editing --- .../alerts/AlertRuleEditor.kt | 93 +++++++++++++++++++ .../alerts/AlertRuleEditorTest.kt | 65 +++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt create mode 100644 app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditorTest.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt new file mode 100644 index 0000000..bb9f5bc --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt @@ -0,0 +1,93 @@ +package se.ajpanton.notificationsmaster.alerts + +import java.util.UUID + +/** All editable rule fields apart from app assignment, local order, and identity. */ +data class AlertRuleDefinition( + 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(sources.isNotEmpty()) + require((outcome == AlertOutcome.PLAY_PROFILE) == (profileId != null)) + require(name?.isNotBlank() != false) + } +} + +data class ConsolidatedAlertRule( + val definition: AlertRuleDefinition, + val ruleIds: Set, + val packageNames: Set, +) + +object AlertRuleEditor { + fun consolidate(rules: Iterable): List = + rules.groupBy(AlertRule::groupingKey).values.map { group -> + ConsolidatedAlertRule( + definition = group.first().definition(), + ruleIds = group.mapTo(linkedSetOf()) { it.id }, + packageNames = group.mapTo(sortedSetOf()) { it.packageName }, + ) + }.sortedWith(compareBy { it.definition.name ?: "" }.thenBy { it.packageNames.first() }) + + fun updateConsolidated( + configuration: AlertConfiguration, + ruleIds: Set, + definition: AlertRuleDefinition, + ): AlertConfiguration = configuration.copy( + rules = configuration.rules.map { rule -> + if (rule.id in ruleIds) rule.withDefinition(definition) else rule + }, + ) + + fun updateForApp( + configuration: AlertConfiguration, + ruleId: String, + definition: AlertRuleDefinition, + ): AlertConfiguration = updateConsolidated(configuration, setOf(ruleId), definition) + + fun addForApps( + configuration: AlertConfiguration, + packageNames: Iterable, + definition: AlertRuleDefinition, + newId: () -> String = { UUID.randomUUID().toString() }, + ): AlertConfiguration { + val added = packageNames.distinct().map { packageName -> + AlertRule( + id = newId(), + packageName = packageName, + order = configuration.rules.filter { it.packageName == packageName }.maxOfOrNull { it.order }?.plus(1) ?: 0, + sources = definition.sources, + matcher = definition.matcher, + outcome = definition.outcome, + profileId = definition.profileId, + playToCompletion = definition.playToCompletion, + allowDuringDnd = definition.allowDuringDnd, + enabled = definition.enabled, + name = definition.name, + ) + } + return configuration.copy(rules = configuration.rules + added) + } + + private fun AlertRule.definition() = AlertRuleDefinition( + sources, matcher, outcome, profileId, playToCompletion, allowDuringDnd, enabled, name, + ) + + private fun AlertRule.withDefinition(definition: AlertRuleDefinition) = copy( + sources = definition.sources, + matcher = definition.matcher, + outcome = definition.outcome, + profileId = definition.profileId, + playToCompletion = definition.playToCompletion, + allowDuringDnd = definition.allowDuringDnd, + enabled = definition.enabled, + name = definition.name, + ) +} diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditorTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditorTest.kt new file mode 100644 index 0000000..7551161 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditorTest.kt @@ -0,0 +1,65 @@ +package se.ajpanton.notificationsmaster.alerts + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AlertRuleEditorTest { + private val profile = AlertProfile("profile", "Profile", "content://sound") + private val definition = AlertRuleDefinition(setOf(AlertSource.NOTIFICATION_POST), outcome = AlertOutcome.PLAY_PROFILE, profileId = profile.id) + + @Test + fun consolidatesIdenticalRulesAcrossApps() { + val consolidated = AlertRuleEditor.consolidate(listOf(rule("one", "chat.app", 1), rule("two", "mail.app", 4))) + + assertEquals(1, consolidated.size) + assertEquals(setOf("one", "two"), consolidated.single().ruleIds) + assertEquals(setOf("chat.app", "mail.app"), consolidated.single().packageNames) + } + + @Test + fun globalEditPreservesEveryAppsIdentityAndOrder() { + val original = configuration(rule("one", "chat.app", 1), rule("two", "mail.app", 4)) + val edited = AlertRuleEditor.updateConsolidated( + original, + setOf("one", "two"), + definition.copy(name = "All messages", playToCompletion = true), + ) + + assertEquals(listOf("one", "two"), edited.rules.map { it.id }) + assertEquals(listOf("chat.app", "mail.app"), edited.rules.map { it.packageName }) + assertEquals(listOf(1, 4), edited.rules.map { it.order }) + assertEquals(listOf("All messages", "All messages"), edited.rules.map { it.name }) + } + + @Test + fun appEditSplitsOnlyThatAppsRule() { + val original = configuration(rule("one", "chat.app", 1), rule("two", "mail.app", 4)) + val edited = AlertRuleEditor.updateForApp( + original, + "one", + definition.copy(matcher = AlertTextMatcher(mode = AlertTextMode.CONTAINS, value = "urgent")), + ) + + assertEquals(2, AlertRuleEditor.consolidate(edited.rules).size) + assertEquals(AlertTextMode.CONTAINS, edited.rules.first { it.id == "one" }.matcher.mode) + assertEquals(AlertTextMode.ANY, edited.rules.first { it.id == "two" }.matcher.mode) + } + + @Test + fun newRuleAppendsAfterExistingRulesForEachApp() { + val original = configuration(rule("one", "chat.app", 2)) + val ids = iterOf("chat-new", "mail-new") + val edited = AlertRuleEditor.addForApps(original, listOf("chat.app", "mail.app"), definition, ids::next) + + assertEquals(listOf(2, 3, 0), edited.rules.map { it.order }) + assertEquals(listOf("chat-new", "mail-new"), edited.rules.drop(1).map { it.id }) + } + + private fun rule(id: String, packageName: String, order: Int) = AlertRule( + id, packageName, order, definition.sources, definition.matcher, definition.outcome, definition.profileId, + ) + + private fun configuration(vararg rules: AlertRule) = AlertConfiguration(listOf(profile), rules = rules.toList()) + + private fun iterOf(vararg values: String) = values.iterator() +}