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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<!-- 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+)
|
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||||
is what lets that install happen with NO confirm dialog. The platform
|
is what lets that install happen with NO confirm dialog. The platform
|
||||||
@@ -57,6 +61,29 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
install intent. A PackageInstaller session takes a stream instead,
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.fabledsword.minstrel
|
package com.fabledsword.minstrel
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
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.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
import com.fabledsword.minstrel.nav.NowPlaying
|
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.shared.widgets.LocalCachedTrackIds
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||||
@Inject lateinit var serverHealth: NetworkStatusController
|
@Inject lateinit var serverHealth: NetworkStatusController
|
||||||
|
@Inject lateinit var authStore: AuthStore
|
||||||
|
|
||||||
// Flipped to true when the user taps the media notification (or
|
// Set when the user taps a notification: the media one asks for the full
|
||||||
// any other entry point that asks for the full player). The App
|
// player, a Minstrel notice for what it is about. The App composable
|
||||||
// composable observes this, navigates to NowPlaying once the
|
// navigates there once the NavHost is ready, then calls back to clear it
|
||||||
// NavHost is ready, then calls back to reset the flag so the
|
// so the navigation doesn't re-fire on the next recomposition.
|
||||||
// navigation doesn't re-fire on the next recomposition.
|
private val pendingRoute = MutableStateFlow<Any?>(null)
|
||||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
|
||||||
|
// 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
consumeOpenNowPlayingIntent(intent)
|
consumeRouteIntent(intent)
|
||||||
|
askToNotifyOnceWanted()
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
seedCache = seedCache,
|
seedCache = seedCache,
|
||||||
cachedTrackIds = cachedTrackIds,
|
cachedTrackIds = cachedTrackIds,
|
||||||
serverHealth = serverHealth,
|
serverHealth = serverHealth,
|
||||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
pendingRoute = pendingRoute.asStateFlow(),
|
||||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
onOpenedRoute = { pendingRoute.value = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeOpenNowPlayingIntent(intent)
|
consumeRouteIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
private fun consumeRouteIntent(intent: Intent?) {
|
||||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
if (intent == null) return
|
||||||
pendingOpenNowPlaying.value = true
|
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
|
||||||
|
pendingRoute.value = NowPlaying
|
||||||
// Strip the extra so a subsequent config-change recreation
|
// Strip the extra so a subsequent config-change recreation
|
||||||
// doesn't re-trigger the navigation.
|
// doesn't re-trigger the navigation.
|
||||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
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 {
|
companion object {
|
||||||
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
* so a media-notification tap lands on the full NowPlaying screen
|
* so a media-notification tap lands on the full NowPlaying screen
|
||||||
* instead of whatever shell route MainActivity last rendered. */
|
* instead of whatever shell route MainActivity last rendered. */
|
||||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
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,
|
seedCache: DetailSeedCache,
|
||||||
cachedTrackIds: CachedTrackIds,
|
cachedTrackIds: CachedTrackIds,
|
||||||
serverHealth: NetworkStatusController,
|
serverHealth: NetworkStatusController,
|
||||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
pendingRoute: StateFlow<Any?>,
|
||||||
onOpenedNowPlaying: () -> Unit,
|
onOpenedRoute: () -> Unit,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
gate: AuthGateViewModel = hiltViewModel(),
|
gate: AuthGateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
val pending by pendingRoute.collectAsStateWithLifecycle()
|
||||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDetailSeedCache provides seedCache,
|
LocalDetailSeedCache provides seedCache,
|
||||||
@@ -119,16 +168,14 @@ private fun App(
|
|||||||
// Queue / unauthenticated) bypass the shell entirely.
|
// Queue / unauthenticated) bypass the shell entirely.
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
// Honour a pending notification-tap once the NavHost is
|
// Honour a pending notification-tap once the NavHost is
|
||||||
// mounted. launchSingleTop avoids stacking copies of
|
// mounted. launchSingleTop avoids stacking copies of a
|
||||||
// NowPlaying if the user taps the notification while
|
// screen if the user taps the notification while already
|
||||||
// already on it; the callback clears the flag so a later
|
// on it; the callback clears it so a later recomposition
|
||||||
// recomposition (config change, theme switch) doesn't
|
// (config change, theme switch) doesn't re-navigate.
|
||||||
// re-navigate.
|
|
||||||
LaunchedEffect(pending, navController) {
|
LaunchedEffect(pending, navController) {
|
||||||
if (pending) {
|
val route = pending ?: return@LaunchedEffect
|
||||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
navController.navigate(route) { launchSingleTop = true }
|
||||||
onOpenedNowPlaying()
|
onOpenedRoute()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MinstrelNavGraph(
|
MinstrelNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
|||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
|
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
@@ -75,6 +76,14 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
@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
|
* Same construct-the-singleton trick — PlayEventsReporter's init
|
||||||
* block subscribes to PlayerController.uiState and reports the
|
* block subscribes to PlayerController.uiState and reports the
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ class AuthStore @Inject constructor(
|
|||||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
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 }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// Serialises every cookie persist with the one-time hydration, so a
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
@@ -104,6 +110,7 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
|
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +212,17 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistNormalization(encoded) }
|
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?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -287,6 +305,7 @@ class AuthStore @Inject constructor(
|
|||||||
NormalizationPrefs.serializer(),
|
NormalizationPrefs.serializer(),
|
||||||
normalizationState.value,
|
normalizationState.value,
|
||||||
),
|
),
|
||||||
|
backgroundDelivery = backgroundDeliveryState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+15
-1
@@ -72,6 +72,8 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedNotificationEntity::class,
|
CachedNotificationEntity::class,
|
||||||
CachedNotificationSettingsEntity::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
|
// v11: + cached_notifications and cached_notification_settings, the
|
||||||
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
||||||
// both; nothing to backfill, the first refresh fills them.
|
// 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 —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 11,
|
version = 12,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@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
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// 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)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+9
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface AuthSessionDao {
|
interface AuthSessionDao {
|
||||||
@@ -50,4 +51,12 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized normalization preference. */
|
/** Partial update: change only the serialized normalization preference. */
|
||||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||||
suspend fun setNormalizationJson(json: String?)
|
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
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the user's session cookie, configured
|
* 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.
|
* defaults apply. Kept so offline playback still levels.
|
||||||
*/
|
*/
|
||||||
val normalizationJson: String? = null,
|
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
|
package com.fabledsword.minstrel.events
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -28,9 +32,6 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
private const val SSE_PATH = "/api/events/stream"
|
private const val SSE_PATH = "/api/events/stream"
|
||||||
private const val EVENTS_BUFFER_CAPACITY = 64
|
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
|
* 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
|
* - No client-side timeout — the server emits 15s heartbeats which
|
||||||
* okhttp-sse handles transparently.
|
* okhttp-sse handles transparently.
|
||||||
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
||||||
* restart, network blip) it reconnects with exponential backoff
|
* restart, network blip) it reconnects after [ReconnectBackoff]'s
|
||||||
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
|
* 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
|
* reconnects while still signed in; a sign-out cancels the pending
|
||||||
* retry. Without this a single blip silently kills cross-device
|
* retry. Without this a single blip silently kills cross-device
|
||||||
* reactivity until the next app launch.
|
* 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
|
* The URL passes through the placeholder host that
|
||||||
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
||||||
@@ -62,6 +68,7 @@ class EventsStream @Inject constructor(
|
|||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
|
private val connectivity: ConnectivityObserver,
|
||||||
) {
|
) {
|
||||||
private val factory = EventSources.createFactory(okHttpClient)
|
private val factory = EventSources.createFactory(okHttpClient)
|
||||||
|
|
||||||
@@ -72,10 +79,13 @@ class EventsStream @Inject constructor(
|
|||||||
)
|
)
|
||||||
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
||||||
|
|
||||||
|
private val connectedState = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
|
||||||
|
|
||||||
private var currentSource: EventSource? = null
|
private var currentSource: EventSource? = null
|
||||||
@Volatile private var signedIn = false
|
@Volatile private var signedIn = false
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
private var backoffMs = BASE_BACKOFF_MS
|
private var backoffMs = ReconnectBackoff.BASE_MS
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -85,13 +95,30 @@ class EventsStream @Inject constructor(
|
|||||||
.collect { isSignedIn ->
|
.collect { isSignedIn ->
|
||||||
signedIn = isSignedIn
|
signedIn = isSignedIn
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
backoffMs = BASE_BACKOFF_MS
|
backoffMs = ReconnectBackoff.BASE_MS
|
||||||
connect()
|
connect()
|
||||||
} else {
|
} else {
|
||||||
disconnect()
|
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
|
@Synchronized
|
||||||
@@ -106,20 +133,22 @@ class EventsStream @Inject constructor(
|
|||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
currentSource?.cancel()
|
currentSource?.cancel()
|
||||||
currentSource = null
|
currentSource = null
|
||||||
|
connectedState.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a reconnect after the current backoff, then double it
|
* Schedule a reconnect after the current backoff, jittered, then
|
||||||
* (capped). No-op when signed out — sign-out's [disconnect]
|
* double it (capped). No-op when signed out — sign-out's [disconnect]
|
||||||
* cancels the pending job. A successful [Listener.onOpen] resets
|
* cancels the pending job. A successful [Listener.onOpen] resets
|
||||||
* the backoff to the floor.
|
* the backoff to the floor.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
|
connectedState.value = false
|
||||||
if (!signedIn) return
|
if (!signedIn) return
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
val waitMs = backoffMs
|
val waitMs = ReconnectBackoff.jittered(backoffMs)
|
||||||
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
|
backoffMs = ReconnectBackoff.next(backoffMs)
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(waitMs)
|
delay(waitMs)
|
||||||
if (signedIn) connect()
|
if (signedIn) connect()
|
||||||
@@ -136,7 +165,8 @@ class EventsStream @Inject constructor(
|
|||||||
|
|
||||||
private inner class Listener : EventSourceListener() {
|
private inner class Listener : EventSourceListener() {
|
||||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||||
backoffMs = BASE_BACKOFF_MS
|
backoffMs = ReconnectBackoff.BASE_MS
|
||||||
|
connectedState.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
// App returned to the foreground. SSE will catch up but might
|
// 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.
|
// defensively.
|
||||||
|
eventsStream.reconnectNow()
|
||||||
refreshLikes()
|
refreshLikes()
|
||||||
refreshNotifications()
|
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. */
|
/** On sign-out: the next account on this device must not see these. */
|
||||||
suspend fun clearLocal() {
|
suspend fun clearLocal() {
|
||||||
dao.clear()
|
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
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,6 +35,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -62,13 +69,7 @@ fun NotificationSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val backgroundDelivery by viewModel.backgroundDelivery.collectAsStateWithLifecycle()
|
||||||
// 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 { }
|
|
||||||
}
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = ShellContentWindowInsets,
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -88,8 +89,7 @@ fun NotificationSettingsScreen(
|
|||||||
SettingsBody(
|
SettingsBody(
|
||||||
settings = s,
|
settings = s,
|
||||||
isAdmin = isAdmin,
|
isAdmin = isAdmin,
|
||||||
// Null while the system lets Minstrel post notifications.
|
deviceRows = { DeviceRows(backgroundDelivery, viewModel::setBackgroundDelivery) },
|
||||||
onPhoneBlocked = if (phoneAllowed) null else ({ openAppNotificationSettings(context) }),
|
|
||||||
onSet = viewModel::set,
|
onSet = viewModel::set,
|
||||||
modifier = Modifier.padding(inner),
|
modifier = Modifier.padding(inner),
|
||||||
)
|
)
|
||||||
@@ -101,7 +101,7 @@ fun NotificationSettingsScreen(
|
|||||||
private fun SettingsBody(
|
private fun SettingsBody(
|
||||||
settings: NotificationSettingsWire,
|
settings: NotificationSettingsWire,
|
||||||
isAdmin: Boolean,
|
isAdmin: Boolean,
|
||||||
onPhoneBlocked: (() -> Unit)?,
|
deviceRows: @Composable () -> Unit,
|
||||||
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -111,9 +111,7 @@ private fun SettingsBody(
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
if (onPhoneBlocked != null) {
|
deviceRows()
|
||||||
PhoneBlockedRow(onClick = onPhoneBlocked)
|
|
||||||
}
|
|
||||||
HeaderRow()
|
HeaderRow()
|
||||||
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
val adminKinds = settings.kinds.filter { it.adminOnly }
|
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
|
@Composable
|
||||||
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
+9
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
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.NotificationChannel
|
||||||
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -20,7 +21,11 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class NotificationSettingsViewModel @Inject constructor(
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
private val repository: NotificationSettingsRepository,
|
private val repository: NotificationSettingsRepository,
|
||||||
authController: AuthController,
|
authController: AuthController,
|
||||||
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : 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
|
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
.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) {
|
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||||
viewModelScope.launch { repository.set(kind, channel, value) }
|
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