From 9940bc375dab7709a3b0238132d8124b06995b1c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 07:59:11 -0400 Subject: [PATCH] feat(android): notifications when the app is closed, a specialUse delivery service (#5347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roundtable's shape: no FCM. A specialUse foreground service keeps the process alive; EventsStream stays connected; DeliveryLauncher runs the catch-up on every notification.created nudge and every reconnect, and is the single owner of the service's lifetime (signed in AND the device's "Notifications when the app is closed", on by default). - NotificationSync pulls the newest page and announces unread notices past a high-water mark (auth_session.notifiedUpTo, read and written through the DAO), honouring the per-kind phone pref. A first look sets the mark without announcing; more than three collapse to one line; nothing is posted while the app is on screen. - Two channels: "Your requests" and "Library health"; the ongoing notice sits on a MIN "Background connection" channel. - EventsStream: a connected flow, 2s→5min backoff with ±25% jitter, and an immediate reconnect when a network comes up (a hint, not VALIDATED) or the app comes to the foreground. - BootReceiver restarts delivery after a reboot or a self-update. - A tap opens what the notice links to (routeForLink), a pile the inbox. - POST_NOTIFICATIONS is asked for on Android 13+ once delivery is wanted, and again when the toggle is turned on. - Room v12: auth_session.backgroundDelivery and notifiedUpTo. Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/AndroidManifest.xml | 27 ++++ .../com/fabledsword/minstrel/MainActivity.kt | 97 +++++++++---- .../minstrel/MinstrelApplication.kt | 9 ++ .../fabledsword/minstrel/auth/AuthStore.kt | 19 +++ .../minstrel/cache/db/AppDatabase.kt | 16 ++- .../minstrel/cache/db/DatabaseModule.kt | 2 +- .../minstrel/cache/db/dao/AuthSessionDao.kt | 9 ++ .../cache/db/entities/AuthSessionEntity.kt | 15 ++ .../minstrel/events/EventsStream.kt | 54 ++++++-- .../minstrel/events/LiveEventsDispatcher.kt | 4 +- .../minstrel/events/ReconnectBackoff.kt | 31 +++++ .../data/NotificationsRepository.kt | 3 + .../notifications/delivery/BootReceiver.kt | 46 +++++++ .../delivery/DeliveryLauncher.kt | 69 ++++++++++ .../notifications/delivery/DeliveryPlan.kt | 94 +++++++++++++ .../notifications/delivery/DeliveryService.kt | 124 +++++++++++++++++ .../delivery/NotificationPoster.kt | 86 ++++++++++++ .../delivery/NotificationSync.kt | 79 +++++++++++ .../ui/NotificationSettingsScreen.kt | 76 +++++++++-- .../ui/NotificationSettingsViewModel.kt | 9 ++ .../src/main/res/drawable/ic_notification.xml | 21 +++ .../minstrel/events/ReconnectBackoffTest.kt | 24 ++++ .../delivery/DeliveryPlanTest.kt | 128 ++++++++++++++++++ 23 files changed, 989 insertions(+), 53 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/events/ReconnectBackoff.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/BootReceiver.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/DeliveryLauncher.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlan.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/DeliveryService.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/NotificationPoster.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/notifications/delivery/NotificationSync.kt create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt 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)) + } +}