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
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:
@@ -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 {
|
||||
|
||||
+15
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+9
@@ -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?)
|
||||
}
|
||||
|
||||
+15
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
@@ -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()
|
||||
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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"
|
||||
}
|
||||
}
|
||||
+94
@@ -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"
|
||||
+124
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
-13
@@ -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(
|
||||
|
||||
+9
@@ -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")
|
||||
}
|
||||
}
|
||||
+128
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user