feat(android): notifications when the app is closed, a specialUse delivery service (#5347)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m31s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 6m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:59:11 -04:00
co-authored by Claude Opus 5.5
parent 63709a433d
commit 9940bc375d
23 changed files with 989 additions and 53 deletions
+27
View File
@@ -8,6 +8,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): the delivery
service, and starting it again after a reboot or an update. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
@@ -57,6 +61,29 @@
</intent-filter>
</service>
<!-- Keeps the process alive so notifications arrive with the app
closed. specialUse: dataSync is stopped after six hours on
Android 15, and shortService after three minutes. -->
<service
android:name=".notifications.delivery.DeliveryService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
</service>
<!-- Starts delivery after a reboot or an update; both broadcasts may
start a foreground service from the background. -->
<receiver
android:name=".notifications.delivery.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead,
@@ -1,10 +1,14 @@
package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
@@ -27,7 +34,10 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
import com.fabledsword.minstrel.notifications.ui.routeForLink
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
// composable observes this, navigates to NowPlaying once the
// NavHost is ready, then calls back to reset the flag so the
// navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
// Set when the user taps a notification: the media one asks for the full
// player, a Minstrel notice for what it is about. The App composable
// navigates there once the NavHost is ready, then calls back to clear it
// so the navigation doesn't re-fire on the next recomposition.
private val pendingRoute = MutableStateFlow<Any?>(null)
// The answer needs no handling: the system remembers it, and the
// notification settings screen reads it on every resume.
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
askToNotifyOnceWanted()
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
pendingRoute = pendingRoute.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
}
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
private fun consumeRouteIntent(intent: Intent?) {
if (intent == null) return
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
pendingRoute.value = NowPlaying
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
// A notice the app has no screen for, or a pile of them, opens
// the inbox.
pendingRoute.value = routeForLink(link) ?: Notifications
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
}
}
/**
* Android 13+ asks before an app may post notifications (M489 #5347).
* Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to
* the system page.
*/
private fun askToNotifyOnceWanted() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
lifecycleScope.launch {
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
val permission = Manifest.permission.POST_NOTIFICATIONS
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
PackageManager.PERMISSION_GRANTED
if (!granted) askToNotify.launch(permission)
}
}
companion object {
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
* so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
/** PendingIntent extra on a Minstrel notice: the web path it links to,
* or empty for a pile, which opens the inbox. */
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
}
}
@@ -92,15 +141,15 @@ private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
pendingRoute: StateFlow<Any?>,
onOpenedRoute: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
@@ -119,16 +168,14 @@ private fun App(
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of
// NowPlaying if the user taps the notification while
// already on it; the callback clears the flag so a later
// recomposition (config change, theme switch) doesn't
// re-navigate.
// mounted. launchSingleTop avoids stacking copies of a
// screen if the user taps the notification while already
// on it; the callback clears it so a later recomposition
// (config change, theme switch) doesn't re-navigate.
LaunchedEffect(pending, navController) {
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
val route = pending ?: return@LaunchedEffect
navController.navigate(route) { launchSingleTop = true }
onOpenedRoute()
}
MinstrelNavGraph(
navController = navController,
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
@@ -75,6 +76,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
* notification would ever be posted.
*/
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
/**
* Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the
@@ -81,6 +81,12 @@ class AuthStore @Inject constructor(
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and
// written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true)
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
@@ -104,6 +110,7 @@ class AuthStore @Inject constructor(
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
}
}
}
@@ -205,6 +212,17 @@ class AuthStore @Inject constructor(
scope.launch { persistNormalization(encoded) }
}
fun setBackgroundDelivery(value: Boolean) {
backgroundDeliveryState.value = value
scope.launch {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(backgroundDelivery = value))
} else {
dao.setBackgroundDelivery(value)
}
}
}
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -287,6 +305,7 @@ class AuthStore @Inject constructor(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
)
companion object {
@@ -72,6 +72,8 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedNotificationEntity::class,
CachedNotificationSettingsEntity::class,
],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (M489 #5347).
// v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them.
@@ -90,7 +92,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 11,
version = 12,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -153,3 +155,15 @@ val MIGRATION_10_11: Migration = object : Migration(10, 11) {
)
}
}
/**
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything.
*/
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
}
}
@@ -38,7 +38,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface AuthSessionDao {
@@ -50,4 +51,12 @@ interface AuthSessionDao {
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
suspend fun setNormalizationJson(json: String?)
/** Partial update: change only the background-delivery choice. */
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
suspend fun setBackgroundDelivery(enabled: Boolean)
/** Partial update: change only the shade's high-water mark. */
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
suspend fun setNotifiedUpTo(upTo: Instant?)
}
@@ -1,7 +1,9 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Single-row table holding the user's session cookie, configured
@@ -49,4 +51,17 @@ data class AuthSessionEntity(
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
/**
* "Notifications when the app is closed" (M489 #5347): keep the
* delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks.
*/
@ColumnInfo(defaultValue = "1")
val backgroundDelivery: Boolean = true,
/**
* The newest notice this device has announced in the shade. A catch-up
* announces only what is newer, so a reboot or a reconnect never
* re-announces a backlog. Cleared on sign-out.
*/
val notifiedUpTo: Instant? = null,
)
@@ -1,14 +1,18 @@
package com.fabledsword.minstrel.events
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -28,9 +32,6 @@ import javax.inject.Singleton
private const val SSE_PATH = "/api/events/stream"
private const val EVENTS_BUFFER_CAPACITY = 64
private const val BASE_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_FACTOR = 2
/**
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
@@ -45,11 +46,16 @@ private const val BACKOFF_FACTOR = 2
* - No client-side timeout — the server emits 15s heartbeats which
* okhttp-sse handles transparently.
* - Reconnect-with-backoff: if the stream drops mid-session (server
* restart, network blip) it reconnects with exponential backoff
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
* restart, network blip) it reconnects after [ReconnectBackoff]'s
* jittered wait (2s doubling to 5 min), reset on a successful open.
* A network coming up reconnects at once: the callback is a hint, and
* the only test of whether the server is reachable is trying it. Only
* reconnects while still signed in; a sign-out cancels the pending
* retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a
* frame sent while the stream was down.
*
* The URL passes through the placeholder host that
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
@@ -62,6 +68,7 @@ class EventsStream @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val okHttpClient: OkHttpClient,
private val json: Json,
private val connectivity: ConnectivityObserver,
) {
private val factory = EventSources.createFactory(okHttpClient)
@@ -72,10 +79,13 @@ class EventsStream @Inject constructor(
)
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
private val connectedState = MutableStateFlow(false)
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
private var currentSource: EventSource? = null
@Volatile private var signedIn = false
private var reconnectJob: Job? = null
private var backoffMs = BASE_BACKOFF_MS
private var backoffMs = ReconnectBackoff.BASE_MS
init {
scope.launch {
@@ -85,13 +95,30 @@ class EventsStream @Inject constructor(
.collect { isSignedIn ->
signedIn = isSignedIn
if (isSignedIn) {
backoffMs = BASE_BACKOFF_MS
backoffMs = ReconnectBackoff.BASE_MS
connect()
} else {
disconnect()
}
}
}
scope.launch {
connectivity.online.collect { up -> if (up) reconnectNow() }
}
}
/**
* Cuts a pending backoff short: reconnects at once and starts the ladder
* over. For a network that has just come up, or the app coming to the
* foreground, where waiting out a five-minute backoff would leave the
* server unheard from for nothing. Does nothing while a stream is open or
* opening.
*/
@Synchronized
fun reconnectNow() {
if (!signedIn || reconnectJob?.isActive != true) return
backoffMs = ReconnectBackoff.BASE_MS
connect()
}
@Synchronized
@@ -106,20 +133,22 @@ class EventsStream @Inject constructor(
reconnectJob?.cancel()
currentSource?.cancel()
currentSource = null
connectedState.value = false
}
/**
* Schedule a reconnect after the current backoff, then double it
* (capped). No-op when signed out — sign-out's [disconnect]
* Schedule a reconnect after the current backoff, jittered, then
* double it (capped). No-op when signed out — sign-out's [disconnect]
* cancels the pending job. A successful [Listener.onOpen] resets
* the backoff to the floor.
*/
@Synchronized
private fun scheduleReconnect() {
connectedState.value = false
if (!signedIn) return
reconnectJob?.cancel()
val waitMs = backoffMs
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
val waitMs = ReconnectBackoff.jittered(backoffMs)
backoffMs = ReconnectBackoff.next(backoffMs)
reconnectJob = scope.launch {
delay(waitMs)
if (signedIn) connect()
@@ -136,7 +165,8 @@ class EventsStream @Inject constructor(
private inner class Listener : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
backoffMs = BASE_BACKOFF_MS
backoffMs = ReconnectBackoff.BASE_MS
connectedState.value = true
}
override fun onEvent(
@@ -62,8 +62,10 @@ class LiveEventsDispatcher @Inject constructor(
override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might
// not have reconnected yet; flush the cross-screen refreshes
// not have reconnected yet (it may be deep in its backoff, so it
// is told to try now); flush the cross-screen refreshes
// defensively.
eventsStream.reconnectNow()
refreshLikes()
refreshNotifications()
}
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.events
import kotlin.random.Random
/**
* How long [EventsStream] waits before reconnecting (M489 #5347).
*
* The stream is wanted around the clock once notifications arrive with the app
* closed, so a server that is down overnight must not cost a radio wakeup
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come back
* and can least take a spike.
*
* A network coming up (a hint, never a gate) or the app coming to the
* foreground reconnects at once and starts the ladder over.
*/
internal object ReconnectBackoff {
const val BASE_MS = 2_000L
const val MAX_MS = 300_000L
private const val JITTER = 0.25
/** The wait after [currentMs], before jitter. */
fun next(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MAX_MS)
/** [ms] moved by up to a quarter either way. */
fun jittered(ms: Long, random: Random = Random.Default): Long {
val spread = ms * JITTER
return (ms + random.nextDouble(-spread, spread)).toLong()
}
}
@@ -92,6 +92,9 @@ class NotificationsRepository @Inject constructor(
}
}
/** The page the device holds, newest first. */
suspend fun cachedPage(): List<CachedNotificationEntity> = dao.getAll()
/** On sign-out: the next account on this device must not see these. */
suspend fun clearLocal() {
dao.clear()
@@ -0,0 +1,46 @@
package com.fabledsword.minstrel.notifications.delivery
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Starts background delivery after a reboot, and after the app updates itself
* (M489 #5347). Without it, notifications stop until the user next opens the
* app, which is exactly when they least need telling.
*
* Both broadcasts are among the few exemptions allowed to start a foreground
* service from the background, so the start happens here, inside the
* receiver's window, rather than whenever [DeliveryLauncher] next observes it.
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var authStore: AuthStore
@Inject @ApplicationScope lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val pending = goAsync()
scope.launch {
try {
// Bounded (rule 156): the session load gives up after its
// own deadline and answers from what it has.
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
if (deliveryWanted(signedIn, authStore.backgroundDelivery.value)) {
DeliveryService.start(context)
}
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,69 @@
package com.fabledsword.minstrel.notifications.delivery
import android.content.Context
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Phone notifications (M489 #5347). Activated by force-@Inject in
* MinstrelApplication, and the single owner of [DeliveryService]'s lifetime:
* it runs exactly while signed in with "Notifications when the app is closed"
* on. Signing out stops it, so a logged-out device holds no connection.
*
* The catch-up runs here, in the process, whether or not the service does:
* with background delivery off, notifications still reach the shade while the
* process is alive (the app open, or music playing).
*/
@Singleton
class DeliveryLauncher @Inject constructor(
@ApplicationContext private val context: Context,
authStore: AuthStore,
eventsStream: EventsStream,
sync: NotificationSync,
@ApplicationScope scope: CoroutineScope,
) {
init {
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }.distinctUntilChanged()
scope.launch {
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted)
.distinctUntilChanged()
.collect { wanted -> if (wanted) DeliveryService.start(context) else DeliveryService.stop(context) }
}
// A nudge and a reconnect mean the same thing: go and look. The
// stream only runs while signed in, so neither fires signed out.
scope.launch {
eventsStream.events
.filter { it.kind == NOTIFICATION_CREATED }
.collect { sync.catchUp() }
}
scope.launch {
eventsStream.connected.filter { it }.collect { sync.catchUp() }
}
// A sign-out, not the signed-out start a cold launch begins with
// before the session loads: that would wipe the mark on every start.
scope.launch {
var wasSignedIn = false
signedIn.collect { now ->
if (wasSignedIn && !now) sync.forget()
wasSignedIn = now
}
}
}
private companion object {
const val NOTIFICATION_CREATED = "notification.created"
}
}
@@ -0,0 +1,94 @@
package com.fabledsword.minstrel.notifications.delivery
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import kotlinx.datetime.Instant
/**
* The decisions behind phone notifications (M489 #5347), kept free of Android
* so they can be tested as plain functions.
*/
/** A handful each get a line; more than this become one line with a count. */
internal const val INDIVIDUAL_LIMIT = 3
/** What a catch-up announces, and the high-water mark to keep afterwards. */
internal data class CatchUp(
val announce: List<CachedNotificationEntity>,
val upTo: Instant?,
)
/**
* Picks what to announce from the newest page.
*
* - With no mark yet (a fresh sign-in on this device), the mark is set to the
* newest notice and nothing is announced: the user is looking at the app,
* and a backlog in the shade is noise. An empty inbox sets the mark to the
* start of time, so the very first notice is announced.
* - Otherwise: unread notices newer than the mark, whose kind has the phone
* on, oldest first so the newest lands on top of the shade.
* - The mark moves to the newest notice in the page, announced or not, so a
* kind with the phone off is never announced later.
*
* A coalesced notice (tracks missing, now 5) moves its time forward when its
* count grows, so it is announced again with the new count.
*/
internal fun planCatchUp(
page: List<CachedNotificationEntity>,
mark: Instant?,
phoneOn: (String) -> Boolean,
): CatchUp {
val newest = page.maxOfOrNull { it.createdAt }
if (mark == null) return CatchUp(emptyList(), newest ?: Instant.DISTANT_PAST)
val fresh = page
.filter { it.readAt == null && it.createdAt > mark && phoneOn(it.kind) }
.sortedBy { it.createdAt }
val upTo = if (newest != null && newest > mark) newest else mark
return CatchUp(fresh, upTo)
}
/** How a catch-up reaches the shade. */
internal sealed interface Announcement {
data class Each(val items: List<CachedNotificationEntity>) : Announcement
/** Coming back from a day offline is one line, not forty buzzes. */
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
}
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
fresh.isEmpty() -> null
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
// A pile goes where its notices would: library health only when every
// one of them is, so a listener's own news is never filed under it.
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
}
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
internal fun phoneOnFor(settings: NotificationSettingsWire?): (String) -> Boolean {
val byKind = settings?.kinds?.associateBy { it.kind }.orEmpty()
return { kind -> byKind[kind]?.let { it.inbox && it.phone } ?: true }
}
/**
* Android channels, so either audience can be silenced in system settings as
* well as in Minstrel's. The ids are permanent: renaming one orphans the
* user's system-level choice for it.
*/
internal enum class ShadeChannel(val id: String, val title: String, val description: String) {
YOUR_REQUESTS("your_requests", "Your requests", "Approved, declined, and new music arriving"),
LIBRARY_HEALTH("library_health", "Library health", "For admins: requests to review and library problems"),
}
private val requesterKinds = setOf("request_approved", "request_rejected", "request_completed")
/** Everything a listener can receive is theirs; the rest is admin work. */
internal fun shadeChannelFor(kind: String): ShadeChannel =
if (kind in requesterKinds) ShadeChannel.YOUR_REQUESTS else ShadeChannel.LIBRARY_HEALTH
/** The delivery service runs exactly while signed in with background delivery on. */
internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = signedIn && enabled
/** One line for a pile. */
internal fun pileText(count: Int): String = "$count new notifications"
@@ -0,0 +1,124 @@
package com.fabledsword.minstrel.notifications.delivery
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.R
import timber.log.Timber
/**
* Keeps Minstrel's process alive so notifications arrive with the app closed
* (M489 #5347, Roundtable's DeliveryService).
*
* **Why a held connection instead of push.** The APK installs from the
* Minstrel server itself, not the Play Store, so nothing pushes the app onto
* Firebase; the alternatives cost more (UnifiedPush's only embedded
* distributor is Firebase, and ntfy is a second app to install and set up).
*
* **This service holds nothing itself.** [com.fabledsword.minstrel.events.EventsStream]
* stays connected for as long as the process lives and someone is signed in,
* and [DeliveryLauncher] runs the catch-up on every nudge and reconnect. All
* this does is keep the process from being reclaimed. While music plays the
* player's own foreground service does that too; this one still runs, so
* delivery does not depend on playback.
*
* **Typed `specialUse`, and that is not arbitrary.** Android 15 stops a
* `dataSync` service after six hours, and `shortService` is capped at three
* minutes. `specialUse` is the only type that may run as long as the user
* wants it to.
*
* The quiet notice in the shade is the honest price of not using Google's
* push, and the settings screen says so.
*/
class DeliveryService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
ensureChannel()
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, ongoing(), type)
// If the system reclaims the process, bring it back: the point is to
// be running when nothing else is.
return START_STICKY
}
private fun ongoing(): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText("Listening for notifications")
.setOngoing(true)
.setContentIntent(open)
// MIN: at the bottom of the shade, no sound, no heads-up. It has
// to exist; it does not have to be loud.
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun ensureChannel() {
val manager = getSystemService(NotificationManager::class.java) ?: return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Background connection", NotificationManager.IMPORTANCE_MIN)
.apply { description = "The quiet notice shown while Minstrel listens for notifications" },
)
}
companion object {
const val ACTION_STOP = "com.fabledsword.minstrel.DELIVERY_STOP"
private const val CHANNEL_ID = "background_connection"
private const val NOTIFICATION_ID = 4801
/**
* Starting a foreground service from the background is refused on
* Android 12+ outside the exemptions (boot, an update, the app on
* screen). A refusal is logged, not thrown: the app's next start, or
* the next boot, tries again.
*/
fun start(context: Context) {
try {
context.startForegroundService(Intent(context, DeliveryService::class.java))
} catch (
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
) {
Timber.w(e, "delivery service: start refused")
}
}
fun stop(context: Context) {
try {
context.startService(Intent(context, DeliveryService::class.java).setAction(ACTION_STOP))
} catch (
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
) {
// Not running and not startable from here: nothing to stop.
Timber.i(e, "delivery service: stop not delivered")
}
}
}
}
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.notifications.delivery
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.R
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Puts an [Announcement] in the shade (M489 #5347). The words are the
* server's: the same title and body the inbox shows. Tapping one opens what it
* is about; tapping a pile opens the inbox.
*/
@Singleton
class NotificationPoster @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Checked just below: posting is skipped when the user has said no.
@SuppressLint("MissingPermission")
internal fun post(announcement: Announcement) {
val manager = NotificationManagerCompat.from(context)
// Denied POST_NOTIFICATIONS, or every channel silenced: say nothing.
// The inbox still has it all.
if (!manager.areNotificationsEnabled()) return
ensureChannels()
when (announcement) {
is Announcement.Each -> announcement.items.forEach { manager.notify(it.id.hashCode(), single(it)) }
is Announcement.Pile -> manager.notify(PILE_ID, pile(announcement))
}
}
private fun single(item: CachedNotificationEntity) =
NotificationCompat.Builder(context, shadeChannelFor(item.kind).id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(item.title)
.setContentText(item.body)
.setStyle(NotificationCompat.BigTextStyle().bigText(item.body))
.setWhen(item.createdAt.toEpochMilliseconds())
.setShowWhen(true)
.setAutoCancel(true)
.setContentIntent(openIntent(item.link, item.id.hashCode()))
.build()
private fun pile(p: Announcement.Pile) =
NotificationCompat.Builder(context, p.channel.id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText(pileText(p.count))
.setAutoCancel(true)
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
.build()
/** Each notice gets its own request code, so their links stay apart. */
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
PendingIntent.getActivity(
context,
requestCode,
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MainActivity.EXTRA_NOTIFICATION_LINK, link.orEmpty()),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun ensureChannels() {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
ShadeChannel.entries.forEach { c ->
manager.createNotificationChannel(
NotificationChannel(c.id, c.title, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = c.description },
)
}
}
private companion object {
const val PILE_ID = 4802
}
}
@@ -0,0 +1,79 @@
package com.fabledsword.minstrel.notifications.delivery
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Decides what the shade says by asking the server, never by trusting a frame
* (M489 #5347, Roundtable's NotificationSync).
*
* A `notification.created` frame and a reconnect both mean "go and look", so
* both run [catchUp]. The frame carries nothing, so nothing can be announced
* twice: the answer is the server's unread state and this device's
* high-water mark, which survive a reboot. A notice raised while the phone was
* unreachable is not lost either: the event bus replays nothing, but the row
* is still there, unread.
*/
@Singleton
class NotificationSync @Inject constructor(
private val notifications: NotificationsRepository,
private val settings: NotificationSettingsRepository,
private val prefs: AuthSessionDao,
private val poster: NotificationPoster,
) {
// One catch-up at a time: a nudge arriving with a reconnect must not
// read the mark before the other has moved it.
private val mutex = Mutex()
/**
* Announces what is new since the mark, then moves it. Failure is quiet on
* purpose: this runs on a reconnect, exactly when a request is most likely
* to lose a race with a network still settling, and the next trigger tries
* again. While the app is on screen nothing is posted (the bell shows it),
* but the mark still moves, so it is not announced later either.
*/
suspend fun catchUp() {
mutex.withLock { catchUpLocked() }
}
private suspend fun catchUpLocked() {
try {
notifications.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "notification catch-up: refresh failed; next trigger retries")
return
}
val plan = planCatchUp(
page = notifications.cachedPage(),
mark = prefs.get()?.notifiedUpTo,
phoneOn = phoneOnFor(settings.settings.first()),
)
if (!appOnScreen()) announcementFor(plan.announce)?.let(poster::post)
plan.upTo?.let { prefs.setNotifiedUpTo(it) }
}
/** On sign-out: the next account starts with no mark, so it announces no backlog. */
suspend fun forget() {
mutex.withLock { prefs.setNotifiedUpTo(null) }
}
private suspend fun appOnScreen(): Boolean = withContext(Dispatchers.Main) {
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
}
@@ -2,9 +2,13 @@
package com.fabledsword.minstrel.notifications.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,11 +19,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -29,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
@@ -62,13 +69,7 @@ fun NotificationSettingsScreen(
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
val context = LocalContext.current
// Re-read on every resume: the user may come back from system settings.
var phoneAllowed by remember { mutableStateOf(true) }
LifecycleResumeEffect(Unit) {
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
val backgroundDelivery by viewModel.backgroundDelivery.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
@@ -88,8 +89,7 @@ fun NotificationSettingsScreen(
SettingsBody(
settings = s,
isAdmin = isAdmin,
// Null while the system lets Minstrel post notifications.
onPhoneBlocked = if (phoneAllowed) null else ({ openAppNotificationSettings(context) }),
deviceRows = { DeviceRows(backgroundDelivery, viewModel::setBackgroundDelivery) },
onSet = viewModel::set,
modifier = Modifier.padding(inner),
)
@@ -101,7 +101,7 @@ fun NotificationSettingsScreen(
private fun SettingsBody(
settings: NotificationSettingsWire,
isAdmin: Boolean,
onPhoneBlocked: (() -> Unit)?,
deviceRows: @Composable () -> Unit,
onSet: (String, NotificationChannel, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -111,9 +111,7 @@ private fun SettingsBody(
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
if (onPhoneBlocked != null) {
PhoneBlockedRow(onClick = onPhoneBlocked)
}
deviceRows()
HeaderRow()
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
val adminKinds = settings.kinds.filter { it.adminOnly }
@@ -172,6 +170,58 @@ private fun KindRow(
}
}
/**
* What this device does, above the per-kind grid that follows the account:
* whether notifications arrive with the app closed (M489 #5347), and whether
* the system lets Minstrel post them at all.
*/
@Composable
private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boolean) -> Unit) {
val context = LocalContext.current
// Re-read on every resume: the user may come back from system settings.
var phoneAllowed by remember { mutableStateOf(true) }
LifecycleResumeEffect(Unit) {
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
val askToNotify = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
phoneAllowed = granted
}
BackgroundDeliveryRow(
checked = backgroundDelivery,
onChange = { on ->
onBackgroundDelivery(on)
// Android 13+: turning it on is the moment to ask, if not yet allowed.
if (on && !phoneAllowed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
askToNotify.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
)
if (!phoneAllowed) {
PhoneBlockedRow(onClick = { openAppNotificationSettings(context) })
}
}
@Composable
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
// The ongoing notice is the price of not using Google's push;
// said plainly rather than left to be discovered.
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
}
Switch(checked = checked, onCheckedChange = null)
}
}
@Composable
private fun PhoneBlockedRow(onClick: () -> Unit) {
Row(
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.notifications.data.NotificationChannel
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -20,7 +21,11 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
class NotificationSettingsViewModel @Inject constructor(
private val repository: NotificationSettingsRepository,
authController: AuthController,
private val authStore: AuthStore,
) : ViewModel() {
/** "Notifications when the app is closed": this device's choice (M489 #5347). */
val backgroundDelivery: StateFlow<Boolean> = authStore.backgroundDelivery
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
@@ -36,4 +41,8 @@ class NotificationSettingsViewModel @Inject constructor(
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
viewModelScope.launch { repository.set(kind, channel, value) }
}
fun setBackgroundDelivery(on: Boolean) {
authStore.setBackgroundDelivery(on)
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar icon for Minstrel's own notifications (M489 #5347): Lucide's
bell, stroked in white. The system tints it; only the alpha is used. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,8a6,6 0,0 1,12 0c0,7 3,9 3,9H3s3,-2 3,-9"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M10.3,21a1.94,1.94 0,0 0,3.4 0"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -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")
}
}
@@ -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<Announcement.Pile>(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<Announcement.Pile>(announcementFor(admin)).channel)
val mixed = admin + notice("r", 9)
assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs<Announcement.Pile>(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))
}
}