diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt index df1c7d2..a6be411 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertModels.kt @@ -150,6 +150,22 @@ data class AlertDecision( val allowDuringDnd: Boolean, ) +/** Resolved at match time so queued alerts never point at mutable settings. */ +data class AlertSnapshot( + val soundUri: String?, + val vibrationPattern: List, + val playToCompletion: Boolean, + val allowDuringDnd: Boolean, +) { + init { + require(soundUri != null || vibrationPattern.any { it > 0 }) + } +} + +fun AlertDecision.snapshot(): AlertSnapshot? = profile?.let { + AlertSnapshot(it.soundUri, it.vibrationPattern, playToCompletion, allowDuringDnd) +} + data class AlertEvaluation( val decisions: List, val silenceUnmatchedDirectAlert: Boolean, diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertQueueController.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertQueueController.kt new file mode 100644 index 0000000..8f7aa7d --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertQueueController.kt @@ -0,0 +1,60 @@ +package se.ajpanton.notificationsmaster.alerts + +data class QueuedAlert( + val ruleId: String, + val snapshot: AlertSnapshot, +) + +/** The first alert is playing; any following alerts are waiting. */ +data class AlertQueue( + val alerts: List = emptyList(), +) { + val current get() = alerts.firstOrNull() +} + +data class AlertQueueTransition( + val queue: AlertQueue, + val started: QueuedAlert? = null, + val interrupted: QueuedAlert? = null, + val dropped: QueuedAlert? = null, + val completed: QueuedAlert? = null, +) + +object AlertQueueController { + fun enqueue( + queue: AlertQueue, + incoming: QueuedAlert, + settings: AlertQueueSettings, + ): AlertQueueTransition { + val alerts = queue.alerts + if (alerts.isEmpty()) return AlertQueueTransition(AlertQueue(listOf(incoming)), started = incoming) + + val tail = alerts.last() + val appended = if (tail.snapshot.playToCompletion) alerts + incoming else alerts.dropLast(1) + incoming + val interrupted = tail.takeUnless { tail.snapshot.playToCompletion } + if (appended.size <= settings.maximumLength) { + return AlertQueueTransition( + AlertQueue(appended), + started = incoming.takeIf { tail == alerts.first() && interrupted != null }, + interrupted = interrupted, + ) + } + return when (settings.overflow) { + AlertQueueOverflow.DROP_NEWEST -> AlertQueueTransition(queue, dropped = incoming) + AlertQueueOverflow.DROP_OLDEST -> { + val next = appended.drop(1) + AlertQueueTransition( + AlertQueue(next), + started = next.first(), + interrupted = alerts.first(), + ) + } + } + } + + fun completeCurrent(queue: AlertQueue): AlertQueueTransition { + val current = queue.current ?: return AlertQueueTransition(queue) + val next = queue.alerts.drop(1) + return AlertQueueTransition(AlertQueue(next), started = next.firstOrNull(), completed = current) + } +} diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertQueueControllerTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertQueueControllerTest.kt new file mode 100644 index 0000000..96d7124 --- /dev/null +++ b/app/src/test/java/se/ajpanton/notificationsmaster/alerts/AlertQueueControllerTest.kt @@ -0,0 +1,91 @@ +package se.ajpanton.notificationsmaster.alerts + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AlertQueueControllerTest { + private val normal = alert("normal") + private val protected = alert("protected", protected = true) + private val settings = AlertQueueSettings() + + @Test + fun startsImmediatelyWhenQueueIsEmpty() { + val result = AlertQueueController.enqueue(AlertQueue(), normal, settings) + + assertEquals(listOf(normal), result.queue.alerts) + assertEquals(normal, result.started) + } + + @Test + fun replacesAndInterruptsAnInterruptibleCurrentAlert() { + val result = AlertQueueController.enqueue(AlertQueue(listOf(normal)), alert("next"), settings) + + assertEquals(listOf("next"), result.queue.alerts.map { it.ruleId }) + assertEquals(normal, result.interrupted) + assertEquals("next", result.started?.ruleId) + } + + @Test + fun keepsProtectedCurrentAlertAndReplacesOnlyInterruptibleTail() { + val first = protected + val replaced = alert("replaced") + val result = AlertQueueController.enqueue(AlertQueue(listOf(first, normal)), replaced, settings) + + assertEquals(listOf("protected", "replaced"), result.queue.alerts.map { it.ruleId }) + assertEquals(normal, result.interrupted) + assertNull(result.started) + } + + @Test + fun droppingOldestInterruptsCurrentAndStartsNextAlert() { + val first = protected + val second = alert("second", protected = true) + val third = alert("third", protected = true) + val incoming = alert("incoming") + val queue = AlertQueue(listOf(first, second, third)) + + val result = AlertQueueController.enqueue(queue, incoming, settings) + + assertEquals(listOf("second", "third", "incoming"), result.queue.alerts.map { it.ruleId }) + assertEquals(first, result.interrupted) + assertEquals(second, result.started) + } + + @Test + fun droppingNewestKeepsExistingQueue() { + val queue = AlertQueue(listOf(protected)) + val incoming = alert("incoming") + val result = AlertQueueController.enqueue( + queue, + incoming, + AlertQueueSettings(1, AlertQueueOverflow.DROP_NEWEST), + ) + + assertEquals(queue, result.queue) + assertEquals(incoming, result.dropped) + } + + @Test + fun completingCurrentStartsNextWaitingAlert() { + val next = alert("next") + val result = AlertQueueController.completeCurrent(AlertQueue(listOf(protected, next))) + + assertEquals(listOf(next), result.queue.alerts) + assertEquals(protected, result.completed) + assertEquals(next, result.started) + } + + @Test + fun snapshotDoesNotPointAtTheOriginalProfile() { + val profile = AlertProfile("profile", "Original", "content://original") + val decision = AlertDecision("rule", AlertOutcome.PLAY_PROFILE, profile, false, false) + + assertEquals("content://original", decision.snapshot()?.soundUri) + } + + private fun alert(id: String, protected: Boolean = false) = QueuedAlert( + id, + AlertSnapshot("content://" + id, emptyList(), protected, false), + ) +}