diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b09ec99f..a23ca32d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt
new file mode 100644
index 00000000..f21eebac
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt
@@ -0,0 +1,24 @@
+package com.fabledsword.minstrel.events
+
+import org.junit.jupiter.api.Test
+import kotlin.random.Random
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ReconnectBackoffTest {
+ @Test
+ fun `doubles from two seconds to a five minute cap`() {
+ val ladder = generateSequence(ReconnectBackoff.BASE_MS) { ReconnectBackoff.next(it) }.take(10).toList()
+ assertEquals(listOf(2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 64_000L, 128_000L, 256_000L), ladder.take(8))
+ assertEquals(300_000L, ladder[8])
+ assertEquals(300_000L, ladder[9])
+ }
+
+ @Test
+ fun `jitter stays within a quarter either way and does spread`() {
+ val random = Random(42)
+ val waits = List(1_000) { ReconnectBackoff.jittered(100_000L, random) }
+ assertTrue(waits.all { it in 75_000L..125_000L }, "out of bounds: ${waits.minOrNull()}..${waits.maxOrNull()}")
+ assertTrue(waits.toSet().size > 100, "jitter should spread reconnects")
+ }
+}
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt
new file mode 100644
index 00000000..e4e2c3a7
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt
@@ -0,0 +1,128 @@
+package com.fabledsword.minstrel.notifications.delivery
+
+import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
+import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
+import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
+import kotlinx.datetime.Instant
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class DeliveryPlanTest {
+ private val t0 = Instant.parse("2026-10-08T12:00:00Z")
+
+ private fun notice(
+ id: String,
+ minutes: Long,
+ kind: String = "request_approved",
+ read: Boolean = false,
+ ) = CachedNotificationEntity(
+ id = id,
+ kind = kind,
+ title = "t-$id",
+ body = "b-$id",
+ link = "/requests",
+ createdAt = Instant.fromEpochMilliseconds(t0.toEpochMilliseconds() + minutes * 60_000),
+ readAt = if (read) t0 else null,
+ )
+
+ private val allOn: (String) -> Boolean = { true }
+
+ @Test
+ fun `a first look sets the mark to the newest and announces nothing`() {
+ val page = listOf(notice("b", 5), notice("a", 1))
+ val plan = planCatchUp(page, mark = null, phoneOn = allOn)
+ assertTrue(plan.announce.isEmpty())
+ assertEquals(page[0].createdAt, plan.upTo)
+ }
+
+ @Test
+ fun `a first look at an empty inbox still lets the first notice through`() {
+ val first = planCatchUp(emptyList(), mark = null, phoneOn = allOn)
+ assertEquals(Instant.DISTANT_PAST, first.upTo)
+ val next = planCatchUp(listOf(notice("a", 1)), mark = first.upTo, phoneOn = allOn)
+ assertEquals(listOf("a"), next.announce.map { it.id })
+ }
+
+ @Test
+ fun `announces unread notices newer than the mark, oldest first, and moves the mark`() {
+ val page = listOf(notice("c", 9), notice("b", 6), notice("old", 2), notice("read", 7, read = true))
+ val plan = planCatchUp(page, mark = notice("m", 3).createdAt, phoneOn = allOn)
+ assertEquals(listOf("b", "c"), plan.announce.map { it.id })
+ assertEquals(page[0].createdAt, plan.upTo)
+ }
+
+ @Test
+ fun `nothing new leaves the mark where it was`() {
+ val mark = notice("m", 10).createdAt
+ val plan = planCatchUp(listOf(notice("a", 1)), mark = mark, phoneOn = allOn)
+ assertTrue(plan.announce.isEmpty())
+ assertEquals(mark, plan.upTo)
+ }
+
+ @Test
+ fun `a kind with the phone off is passed over, and not announced later either`() {
+ val page = listOf(notice("h", 5, kind = "tracks_missing"), notice("r", 4))
+ val plan = planCatchUp(page, mark = t0, phoneOn = { it != "tracks_missing" })
+ assertEquals(listOf("r"), plan.announce.map { it.id })
+ assertEquals(page[0].createdAt, plan.upTo, "the mark passes it")
+ }
+
+ @Test
+ fun `phone prefs come from the cached settings, with every kind on when none are cached`() {
+ val settings = NotificationSettingsWire(
+ kinds = listOf(
+ NotificationKindSettingWire("request_approved", false, inbox = true, phone = false, email = true),
+ NotificationKindSettingWire("request_completed", false, inbox = false, phone = true, email = true),
+ NotificationKindSettingWire("request_rejected", false, inbox = true, phone = true, email = true),
+ ),
+ emailAvailable = true,
+ )
+ val phoneOn = phoneOnFor(settings)
+ assertFalse(phoneOn("request_approved"))
+ assertFalse(phoneOn("request_completed"), "phone rides on the inbox")
+ assertTrue(phoneOn("request_rejected"))
+ assertTrue(phoneOnFor(null)("tracks_missing"))
+ }
+
+ @Test
+ fun `three get a line each, more become one line with the count`() {
+ assertNull(announcementFor(emptyList()))
+ val three = (1..3).map { notice("n$it", it.toLong()) }
+ assertEquals(Announcement.Each(three), announcementFor(three))
+
+ val five = (1..5).map { notice("n$it", it.toLong()) }
+ val pile = assertIs(announcementFor(five))
+ assertEquals(5, pile.count)
+ assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
+ assertEquals("5 new notifications", pileText(pile.count))
+ }
+
+ @Test
+ fun `a pile of admin notices goes to library health, a mixed one does not`() {
+ val admin = (1..4).map { notice("a$it", it.toLong(), kind = "tracks_missing") }
+ assertEquals(ShadeChannel.LIBRARY_HEALTH, assertIs(announcementFor(admin)).channel)
+ val mixed = admin + notice("r", 9)
+ assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs(announcementFor(mixed)).channel)
+ }
+
+ @Test
+ fun `listener kinds are their requests, the rest is library health`() {
+ listOf("request_approved", "request_rejected", "request_completed").forEach {
+ assertEquals(ShadeChannel.YOUR_REQUESTS, shadeChannelFor(it))
+ }
+ listOf("request_pending", "quarantine_flagged", "scan_failed", "tracks_missing").forEach {
+ assertEquals(ShadeChannel.LIBRARY_HEALTH, shadeChannelFor(it))
+ }
+ }
+
+ @Test
+ fun `the service runs only signed in with background delivery on`() {
+ assertTrue(deliveryWanted(signedIn = true, enabled = true))
+ assertFalse(deliveryWanted(signedIn = true, enabled = false))
+ assertFalse(deliveryWanted(signedIn = false, enabled = true))
+ }
+}