Add custom alert queue controller

This commit is contained in:
ajp_anton
2026-08-16 17:17:39 +00:00
parent 720db4b7d3
commit c209ef24bb
3 changed files with 167 additions and 0 deletions
@@ -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<Long>,
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<AlertDecision>,
val silenceUnmatchedDirectAlert: Boolean,
@@ -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<QueuedAlert> = 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)
}
}
@@ -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),
)
}