feat: a newer app is told in a snackbar on Home and installed from a sheet holding Settings' card, fetched ahead on Wi-Fi (#6452)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped

Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367).

- Home shows "Version X of the app is on your server." / "…is downloaded
  and ready." with Update / Install; the button opens a bottom sheet
  holding the same About card Settings shows, so there is one install
  path. Each build is told at most once a day, kept on disk.
- The shell-wide update banner and its in-memory dismissals are gone.
- AppUpdates (singleton) holds the offer and the install, so Home and
  Settings show the same state. Checks run when either screen opens, on
  every return to the front, every 5 minutes while shown, and on Home's
  pull to refresh.
- UpdateFetchWorker downloads the newer APK ahead on an unmetered network
  (battery and storage not low, three tries); the download writes a .part
  file and keeps it under the build's name only once its size and sha256
  match, so Install is usually one tap.
- Back from allowing installs, the update the listener asked for carries
  on (kept in SavedStateHandle) instead of asking them to tap again.
- An offer needs a higher code AND a different name: the same app built
  again at a later minute is not offered.
- Server: /api/client/version reports the APK's sha256, worked out from
  the served file and cached by size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 07:46:01 -04:00
co-authored by Claude Opus 5.5
parent de41169ccb
commit fa75bd08d7
23 changed files with 1208 additions and 200 deletions
@@ -24,7 +24,6 @@ import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -155,14 +154,6 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController @Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's
* soft "update available" banner. Without this @Inject the poll
* loop would never start and the banner would never surface.
*/
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
/** /**
* Same construct-the-singleton trick — FreshnessSweeper's init * Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached * block starts a 30-min sweep loop that re-fetches cached
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import com.fabledsword.minstrel.update.ui.UpdateNotices
import com.fabledsword.minstrel.update.ui.UpdateViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
fun HomeScreen( fun HomeScreen(
navController: NavHostController, navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
updates: UpdateViewModel = hiltViewModel(),
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -575,7 +578,11 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle() val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refreshFromPull() }, onRefresh = {
// A pull asks about a newer app too, so one shows up at once.
updates.check()
viewModel.refreshFromPull()
},
modifier = Modifier.fillMaxSize().padding(inner), modifier = Modifier.fillMaxSize().padding(inner),
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -590,6 +597,7 @@ fun HomeScreen(
// hide behind an "Updating your mixes…" wipe that stays up // hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving. // until the screen has actually stopped moving.
UpdatingVeil(visible = updating) UpdatingVeil(visible = updating)
UpdateNotices(updates)
} }
} }
} }
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
* verbatim rather than validated, so an unexpected value is shown rather * verbatim rather than validated, so an unexpected value is shown rather
* than dropped. * than dropped.
* *
* [apkUrl] is server-relative (e.g. `/api/client/apk`). * [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
* served file's digest, in hex; empty from a server that predates it, and
* then a download is judged whole by its size alone.
*/ */
data class UpdateInfo( data class UpdateInfo(
val version: String, val version: String,
@@ -23,4 +25,5 @@ data class UpdateInfo(
val channel: String?, val channel: String?,
val apkUrl: String, val apkUrl: String,
val sizeBytes: Long, val sizeBytes: Long,
val sha256: String,
) )
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
val channel: String? = null, val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk", @SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0, @SerialName("size_bytes") val sizeBytes: Long = 0,
val sha256: String = "",
) )
@@ -51,7 +51,6 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shield import com.composables.icons.lucide.Shield
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.NotificationSettings import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
@@ -61,8 +60,9 @@ import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.data.InstallStage import com.fabledsword.minstrel.update.ui.AppVersionCard
import com.fabledsword.minstrel.update.data.isBusy import com.fabledsword.minstrel.update.ui.CheckWhileShown
import com.fabledsword.minstrel.update.ui.UpdateViewModel
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -367,112 +367,14 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
ThemeMode.DARK -> "Dark" ThemeMode.DARK -> "Dark"
} }
/**
* The About card is the app-version card Home's update sheet holds too; on
* Settings it also asks the server again while the screen is up.
*/
@Composable @Composable
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() CheckWhileShown(model)
ElevatedCard(modifier = Modifier.fillMaxWidth()) { AppVersionCard(model)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Installed: Minstrel ${state.installedVersion} " +
"(${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
UpdateControls(state = state, viewModel = viewModel)
}
}
}
@Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (!state.selfUpdates) {
Text(
text = "Debug build: updates install from Android Studio.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.installStage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChecking) {
ButtonSpinner()
}
Text(if (state.isChecking) "Checking…" else "Check for updates")
}
val available = state.result as? UpdateCheckResult.UpdateAvailable
if (available != null) {
InstallButton(
version = available.info.version,
stage = state.installStage,
onClick = { viewModel.install(available.info) },
)
}
if (state.installMessage != null) {
Text(
text = state.installMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = !stage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (stage.isBusy()) {
ButtonSpinner()
}
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install $version"
},
)
}
}
@Composable
private fun ButtonSpinner() {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
@Composable
private fun UpdateCheckLine(result: UpdateCheckResult) {
val text = when (result) {
UpdateCheckResult.Idle -> return
UpdateCheckResult.Latest -> "You're on the latest version."
is UpdateCheckResult.UpdateAvailable ->
"Update available: ${result.info.version}"
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
@Composable @Composable
@@ -246,7 +246,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
) )
} }
private fun formatBytes(n: Long): String { internal fun formatBytes(n: Long): String {
if (n <= 0L) return "0 B" if (n <= 0L) return "0 B"
return when { return when {
n < KIB -> "$n B" n < KIB -> "$n B"
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
import com.fabledsword.minstrel.update.ui.UpdateBanner
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed * [onExpandPlayer] is the navigate-to-NowPlaying callback passed
* through to MiniPlayer's tap-handler. No NavHostController parameter * through to MiniPlayer's tap-handler. No NavHostController parameter
* because nothing inside the shell currently navigates - if a banner * because nothing inside the shell currently navigates - if a banner
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever * (VersionTooOldBanner / ConnectionErrorBanner) ever
* grows tap-to-navigate, plumb a NavHostController in at that point. * grows tap-to-navigate, plumb a NavHostController in at that point.
*/ */
@Composable @Composable
@@ -97,12 +96,10 @@ fun ShellScaffold(
// the status bar — no double inset, and no banner drawing under it. // the status bar — no double inset, and no banner drawing under it.
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
// Banner slot. VersionTooOldBanner appears when the server's // Banner slot. VersionTooOldBanner appears when the server's
// min_client_version exceeds our build; UpdateBanner softly // min_client_version exceeds our build, and ConnectionErrorBanner
// nudges an available (newer-than-installed) APK; and // shows when the device loses usable internet. A newer app is not
// ConnectionErrorBanner shows when the device loses usable // a banner: Home tells of it in a snackbar (UpdateNotices).
// internet.
VersionTooOldBanner() VersionTooOldBanner()
UpdateBanner()
ConnectionErrorBanner() ConnectionErrorBanner()
// The window is edge-to-edge, so something has to own the // The window is edge-to-edge, so something has to own the
// bottom navigation-bar inset, and the shell claims it once // bottom navigation-bar inset, and the shell claims it once
@@ -6,24 +6,13 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File import java.io.File
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val APK_FILENAME = "minstrel-update.apk"
/** /**
* Downloads the server-bundled APK and installs it over ourselves. * Installs a downloaded APK over ourselves. [UpdateRepository] brings it
* * down; [SelfUpdateSession] streams it from there into a
* The download goes through the shared [OkHttpClient] so it inherits
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir; [SelfUpdateSession] streams it from there into a
* [android.content.pm.PackageInstaller] session. * [android.content.pm.PackageInstaller] session.
* *
* On Android O+ the user must have granted "install unknown apps" * On Android O+ the user must have granted "install unknown apps"
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
@Singleton @Singleton
class ApkInstaller @Inject constructor( class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val session: SelfUpdateSession, private val session: SelfUpdateSession,
) { ) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("http://placeholder.invalid$apkUrl")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IOException("Empty download body")
val file = File(context.cacheDir, APK_FILENAME)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
file
}
}
/** True when the system will accept an install intent without a permission detour. */ /** True when the system will accept an install intent without a permission detour. */
fun canInstall(): Boolean = fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
@@ -0,0 +1,196 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class UpdateUiState(
/** Null when there is nothing newer to offer. */
val offer: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val progress: Float = 0f,
val problem: String? = null,
/** Asking the server which app it offers. */
val checking: Boolean = false,
/** Why the last ask got no answer, when it did not. */
val unchecked: String? = null,
/** The offer is already downloaded and checked: Install is all that is left. */
val ready: Boolean = false,
)
/** A newer build to tell the listener of, and whether it is already here to install. */
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
/**
* The one record of whether a newer app is on offer and where installing it
* has got to, shared by Home (which tells of it) and the About card (in
* Settings, and in the sheet Home's news opens). A singleton because each
* screen gets its own view model, and an install started from one must
* show on the other.
*
* Only the platform's answer retires an offer: declined, failed or refused,
* it stays on screen. A newer build is fetched ahead on Wi-Fi
* ([UpdateScheduler]), so the listener is usually told it is ready and
* Install is one tap. Home is told at most once a day for each build
* ([UpdateTold]).
*/
@Singleton
class AppUpdates @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val updates: UpdateRepository,
private val installer: ApkInstaller,
private val told: UpdateTold,
private val fetching: UpdateScheduler,
) {
private val mutable = MutableStateFlow(UpdateUiState())
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
val news: Flow<UpdateNews> = telling.receiveAsFlow()
private var asking: Job? = null
private var tells = false
/**
* Asks the server which app it offers: when Home or Settings opens, on a
* pull, and every few minutes while one is up. A newer one being fetched
* or installed is never taken off the screen by a later answer.
*/
fun check(): Job {
asking?.takeIf { it.isActive }?.let { return it }
return scope.launch { refresh() }.also { asking = it }
}
private suspend fun refresh() {
mutable.update { it.copy(checking = true) }
val offer = updates.check()
mutable.update { now ->
val busy = now.stage != InstallStage.IDLE
when (offer) {
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
is Offer.Problem -> now.copy(unchecked = offer.message)
}.copy(checking = false)
}
fetchOrTidy(offer)
lookForReady()
tellIfDue()
}
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
private suspend fun fetchOrTidy(offer: Offer) {
if (mutable.value.stage != InstallStage.IDLE) return
when (offer) {
is Offer.Newer -> fetching.fetchAhead()
Offer.Current -> updates.tidy()
is Offer.Problem -> Unit
}
}
/** Home is up: what is on offer now is told, if it is due. */
fun tellHere() {
tells = true
tellIfDue()
}
private fun tellIfDue() {
val now = mutable.value
val offer = now.offer
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
val key = buildKey(offer.code, offer.version)
val at = System.currentTimeMillis()
if (dueToSay(key, told.build(), told.at(), at)) {
told.told(key, at)
telling.trySend(UpdateNews(offer, now.ready))
}
}
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
fun lookForReady() {
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
}
fun mayInstall(): Boolean = installer.canInstall()
/** Sends the listener to allow installs; the update carries on when they come back. */
fun askToInstall() {
mutable.update { it.copy(problem = ALLOW) }
installer.requestInstallPermission()
}
/** Came back from allowing installs without allowing them. */
fun refused() {
mutable.update { it.copy(problem = REFUSED) }
}
/**
* Downloads the offer (or takes the one already here) and installs it.
* Runs in the app's scope, so leaving the screen does not stop it.
*/
fun fetchAndInstall() {
val offer = mutable.value.offer ?: return
if (mutable.value.stage != InstallStage.IDLE) return
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
scope.launch {
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
when (download) {
is Download.Problem -> mutable.update {
it.copy(stage = InstallStage.IDLE, problem = download.message)
}
is Download.Ready -> {
mutable.update { it.copy(ready = true) }
install(download.apk)
}
}
}
}
private suspend fun install(apk: File) {
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
// On a successful silent install this never returns: the process is replaced.
val outcome = installer.install(apk)
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
}
private companion object {
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
const val REFUSED =
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
}
}
/**
* What coming back to the app does to an update the listener asked for and
* was sent to allow installs for.
*/
internal enum class AfterAsking {
/** Allowed now: carry on with the update they already asked for. */
FETCH,
/** Came back without allowing it. The offer stays; they are told. */
REFUSED,
/** Nothing asked, something already under way, or the offer not known yet. */
WAIT,
}
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
when {
!asked || busy || !offered -> AfterAsking.WAIT
mayInstall -> AfterAsking.FETCH
else -> AfterAsking.REFUSED
}
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
} }
/** /**
* Where an install has got to, for the two surfaces that show it: the shell's * Where an install has got to, for the card that shows it (in Settings and
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the * in the sheet Home's update news opens).
* Settings About card.
* *
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits * DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
* the platform's verdict and can sit on a confirm dialog indefinitely, so * the platform's verdict and can sit on a confirm dialog indefinitely, so
* one combined state would leave the UI claiming "Downloading…" through it. * one combined state would leave the UI claiming "Downloading…" through it.
* A failure is not a stage: it returns to IDLE with the reason beside it,
* and the offer stays so the listener can try again.
*/ */
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR } enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
/** True while an install is underway and a second tap should do nothing. */
fun InstallStage.isBusy(): Boolean =
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
/**
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
* rather than ERROR — the user chose it, so presenting it as a failure would
* be a lie with a red tint.
*/
fun InstallOutcome.stage(): InstallStage = when (this) {
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
is InstallOutcome.Failed -> InstallStage.ERROR
}
/** /**
* User-facing copy for an outcome; null when there is nothing worth saying. * User-facing copy for an outcome; null when there is nothing worth saying.
* * Lives beside the outcome rather than in the UI package, the same reasoning
* Lives beside the outcome rather than in either UI package because two * that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
* separate screens surface the same verdicts and must not drift — the same * UI layer.
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
* outside the UI layer.
*/ */
fun InstallOutcome.message(): String? = when (this) { fun InstallOutcome.message(): String? = when (this) {
InstallOutcome.Installed -> null InstallOutcome.Installed -> null
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Brings the newer app down before the listener is asked to install it, so
* Install is one tap rather than a wait. It never installs and shows
* nothing: whether to update stays the listener's.
*
* It asks the server again rather than being told what to fetch. WorkManager
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
* server may offer another build.
*/
@HiltWorker
class UpdateFetchWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val updates: UpdateRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = when (val offer = updates.check()) {
// A server out of reach is a phone away from home; the next check asks for this again.
is Offer.Problem -> Result.success()
Offer.Current -> {
updates.tidy()
Result.success()
}
is Offer.Newer -> when (updates.download(offer.info) {}) {
is Download.Ready -> Result.success()
// A dropped connection is worth another go after WorkManager's
// backoff, which also outlasts the server's one-download-a-minute
// limit; a few, not forever.
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
}
}
private companion object {
const val TRIES = 3
}
}
/**
* Asks the platform to fetch the newer app when it is free to: on an
* unmetered network, with the battery and storage not low. Nothing turns
* this onto mobile data, because the listener's Update still downloads at
* once over whatever network there is; this only saves the wait.
*
* Kept rather than replaced, so a check every few minutes never restarts a
* download already under way.
*/
@Singleton
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
fun fetchAhead() {
WorkManager.getInstance(context).enqueueUniqueWork(
FETCHING,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build(),
)
.build(),
)
}
private companion object {
const val FETCHING = "update-fetch"
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.update.api.ClientVersionApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import java.io.File
import java.io.IOException
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UpdateModule {
/**
* The download goes through the shared [OkHttpClient] so it inherits the
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
* server's one-download-a-minute limit, reads as an IOException, which
* the repository reports and the fetch worker retries after a backoff.
*/
@Provides
@Singleton
fun provideUpdateRepository(
@ApplicationContext context: Context,
retrofit: Retrofit,
okHttpClient: OkHttpClient,
): UpdateRepository = UpdateRepository(
api = retrofit.create(ClientVersionApi::class.java),
apk = ApkSource { apkUrl ->
withContext(Dispatchers.IO) {
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed: HTTP ${response.code}")
}
response.body ?: throw IOException("Empty download body")
}
},
dir = File(context.cacheDir, "update"),
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
offers = !BuildConfig.DEBUG,
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
)
}
@@ -1,21 +1,158 @@
package com.fabledsword.minstrel.update.data package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.models.wire.UpdateInfoWire import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi import com.fabledsword.minstrel.update.api.ClientVersionApi
import retrofit2.Retrofit import kotlinx.coroutines.Dispatchers
import javax.inject.Inject import kotlinx.coroutines.sync.Mutex
import javax.inject.Singleton import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
/** What asking the server for its app came to. */
sealed interface Offer {
/** The server's app is another build than this one, and installs over it. */
data class Newer(val info: UpdateInfo) : Offer
/** This is the server's app, or the same app built again, or the server has none to offer. */
data object Current : Offer
/** The server could not be asked. [message] says why, for the About card. */
data class Problem(val message: String) : Offer
}
/** What a download came to. */
sealed interface Download {
data class Ready(val apk: File) : Download
data class Problem(val message: String) : Download
}
/** Opens the server's APK for reading. The caller closes the body. */
fun interface ApkSource {
suspend fun open(apkUrl: String): ResponseBody
}
/** /**
* Thin facade over `/api/client/version`, used by the About card's * Whether the server offers a newer app than this one, and fetching it.
* Check for updates button and by [UpdateBannerController]. *
* One way a package arrives, whether [UpdateFetchWorker] brought it down
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
* turns, and each looks first for what the other already brought, so
* nothing is downloaded twice. A package only gets its build's name once
* it checked out against the size and digest the server gave, so a file at
* that name is that build, whole ([ready]).
*
* A debug build is never offered one ([offers] false): it is signed with the
* debug key, so the platform would refuse the release build over it anyway.
*/ */
@Singleton class UpdateRepository(
class UpdateRepository @Inject constructor(retrofit: Retrofit) { private val api: ClientVersionApi,
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java) private val apk: ApkSource,
private val dir: File,
private val installedCode: Long,
private val installedName: String,
private val offers: Boolean,
// Where the updater before this one kept its download, never cleaned up.
private val legacyApk: File? = null,
) {
private val turns = Mutex()
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain() suspend fun check(): Offer {
if (!offers) return Offer.Current
return try {
val info = api.getVersion().toDomain()
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
Offer.Newer(info)
} else {
Offer.Current
}
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
// 404 is the server saying it bundles no app: nothing to offer.
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: IOException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: SerializationException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
}
}
/** The package for [info], when it is already here and whole. */
fun ready(info: UpdateInfo): File? =
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
/**
* The server's app, checked against the size and digest the server gave,
* so what is handed to the installer is the build that was offered, all
* of it. [progress] is called with 0 to 1 as it arrives.
*/
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
/** Drops every package kept here: none is on offer any more. */
suspend fun tidy() {
turns.withLock {
withContext(Dispatchers.IO) {
dir.listFiles()?.forEach { it.delete() }
legacyApk?.delete()
}
}
}
/**
* Writes to a part file and gives it the build's name only once it
* checks out, so a download cut off partway is never taken for one that
* finished. Any package for another build goes then.
*/
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
val target = waiting(info)
val part = File(dir, target.name + PART)
return try {
val digest = withContext(Dispatchers.IO) {
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
}
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
} catch (e: CancellationException) {
part.delete()
throw e
} catch (e: IOException) {
part.delete()
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
}
}
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
// A server that predates the digest leaves the size as the only check.
val whole = part.length() == info.sizeBytes &&
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
return if (whole && part.renameTo(target)) {
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
Download.Ready(target)
} else {
part.delete()
Download.Problem("The download did not match the app the server offered. Try again.")
}
}
/** Where the package for [info] waits: named for its build. */
private fun waiting(info: UpdateInfo): File =
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
private companion object {
const val PART = ".part"
val UNSAFE = Regex("[^A-Za-z0-9._-]")
}
} }
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo( private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
channel = channel, channel = channel,
apkUrl = apkUrl, apkUrl = apkUrl,
sizeBytes = sizeBytes, sizeBytes = sizeBytes,
sha256 = sha256,
) )
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
file.parentFile?.mkdirs()
val digest = MessageDigest.getInstance("SHA-256")
// Closing the stream closes the body.
body.byteStream().use { input ->
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
val buffer = ByteArray(BUFFER_BYTES)
var total = 0L
var read = input.read(buffer)
while (read >= 0) {
output.write(buffer, 0, read)
digest.update(buffer, 0, read)
total += read
written(total)
read = input.read(buffer)
}
}
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
}
private const val BUFFER_BYTES = 64 * 1024
/** /**
* True when [server] should be offered over the installed build. * True when [serverName] / [serverCode] should be offered over the installed
* build. Two questions, each asked of the value that answers it.
* *
* **Decide on the ordering key whenever the server sends one.** That is the * **May it be installed over this one** is the ordering key's, whenever the
* same value Android's package installer compares, so an offer made this way * server sends one: it is the same value Android's package installer
* implies an install the platform will actually accept. Comparing NAMES * compares, so an offer made on it is an install the platform will take.
* while the platform installs by `versionCode` keeps nothing consistent
* between the two orderings: it can offer a build Android then refuses as
* a downgrade, or stay quiet about one it would take.
* *
* Name comparison survives only as the fallback for a server that predates * **Is it another app at all** is the name's, compared for equality only.
* the field. A null code means "this server cannot tell me" — never "zero" — * Every server image builds the app again at a later minute, so the code
* rises with a CI re-run or a change outside the shipped set while the app
* itself does not; asking only the first question offered the installed app
* to itself after each one.
*
* Name ordering survives only as the fallback for a server that predates the
* code. A null code means "this server cannot tell me" — never "zero" —
* because treating absent as zero would rank every such server as infinitely * because treating absent as zero would rank every such server as infinitely
* old and offer its build to everyone, forever. * old and offer its build to everyone, forever.
*/ */
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
installedName: String, installedName: String,
): Boolean = ): Boolean =
if (serverCode != null) { if (serverCode != null) {
serverCode > installedCode serverCode > installedCode && serverName != installedName
} else { } else {
isVersionNewer(serverName, installedName) isVersionNewer(serverName, installedName)
} }
@@ -0,0 +1,58 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The build Home last told the listener of, and when: on disk, so an app
* Android reopened does not say the same news again.
*/
@Singleton
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun build(): String = prefs.getString(BUILD, "").orEmpty()
fun at(): Long = prefs.getLong(AT, 0L)
fun told(build: String, at: Long) {
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
}
private companion object {
const val PREFS_NAME = "update_told"
const val BUILD = "build"
const val AT = "at"
}
}
/**
* The key a build is told under: its code, or its name from a server that
* sends no code.
*/
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
/**
* Whether a build on offer is said again: a build not yet told of is news at
* once; the same one again a day later, until it is installed. A clock that
* went backwards says it rather than hiding an update for a day.
*/
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
val since = now - toldAt
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
}
/** What Home's snackbar says of an offer; its button opens the sheet. */
internal fun toldWords(version: String, ready: Boolean): String =
if (ready) {
"Version $version of the app is downloaded and ready."
} else {
"Version $version of the app is on your server."
}
/** The snackbar's button: Install when there is nothing left to download. */
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.settings.ui.formatBytes
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateUiState
/**
* The app's own version, the newer one when the server has it, and a check
* that is always there. Settings shows it as its About card, and the sheet
* Home's update news opens holds the very same card, so there is one place
* the app installs from. Back from allowing installs, the update the
* listener asked for carries on.
*/
@Composable
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
LifecycleResumeEffect(model) {
model.resumed()
onPauseOrDispose {}
}
val state by model.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Installed: Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val offer = state.offer
if (offer != null) {
Offered(offer, state, model::update)
} else {
Text(
text = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.problem?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
TextButton(
onClick = model::check,
enabled = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE,
) { Text(if (state.checking) "Checking…" else "Check for updates") }
}
}
}
/** The newer build, and the one button that fetches and installs it. */
@Composable
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium)
when (state.stage) {
InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) {
Text(if (state.ready) "Install" else "Update")
}
InstallStage.DOWNLOADING ->
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium)
}
}
/** Whether this app is the newest, as far as the server has said. */
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
// Its own package and the debug key: the server's app would install beside it, never over it.
!selfUpdates -> "Debug build: updates install from Android Studio."
state.checking -> "Checking for a newer version…"
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
else -> "This is the newest version."
}
/** The newer build, and whether it is already downloaded. */
internal fun offeredWords(offer: UpdateInfo, ready: Boolean): String =
if (ready) {
"A new version of the app is downloaded and ready: ${describe(offer)}"
} else {
"A new version of the app is on your server: ${describe(offer)}"
}
/** The name, the channel when it is the test line, and the size. */
internal fun describe(offer: UpdateInfo): String {
val channel = if (offer.channel == "dev") " (test build)" else ""
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
}
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.fabledsword.minstrel.update.data.toldAction
import com.fabledsword.minstrel.update.data.toldWords
import kotlinx.coroutines.delay
/** How often a screen on show asks the server about a newer app by itself. */
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
/**
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
* snackbar at the foot of the screen, never a dialog, and its button opens a
* sheet holding the very card Settings shows, so there is one place the app
* installs from. Each build is told at most once a day. The sheet stays open
* through a trip to allow installs, which is where the card carries on from.
*/
@Composable
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
val notices = remember { SnackbarHostState() }
var updating by rememberSaveable { mutableStateOf(false) }
CheckWhileShown(model)
LaunchedEffect(model) {
model.tellHere()
model.news.collect { news ->
val tapped = notices.showSnackbar(
message = toldWords(news.offer.version, news.ready),
actionLabel = toldAction(news.ready),
withDismissAction = true,
duration = SnackbarDuration.Long,
)
if (tapped == SnackbarResult.ActionPerformed) updating = true
}
}
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
if (updating) UpdateSheet(model) { updating = false }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = dismiss) {
AppVersionCard(
model,
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
/**
* Asks the server about a newer app when the screen opens, as soon as the
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
* screen. A screen in the background never asks.
*/
@Composable
fun CheckWhileShown(model: UpdateViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(model, lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
model.check()
delay(CHECK_EVERY_MS)
}
}
}
}
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.update.data.AfterAsking
import com.fabledsword.minstrel.update.data.AppUpdates
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateNews
import com.fabledsword.minstrel.update.data.UpdateUiState
import com.fabledsword.minstrel.update.data.afterAsking
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A screen's handle on [AppUpdates]. What it adds is the one thing that
* belongs to the screen: that the listener pressed Update and was sent to
* allow installs. That is kept in [saved], so it outlives the process
* Android may restart from the settings page, and the update carries on
* when they come back ([resumed]) instead of asking them to tap again.
*/
@HiltViewModel
class UpdateViewModel @Inject constructor(
private val updates: AppUpdates,
private val saved: SavedStateHandle,
) : ViewModel() {
val state: StateFlow<UpdateUiState> = updates.state
val news: Flow<UpdateNews> = updates.news
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
fun check() {
viewModelScope.launch {
updates.check().join()
carryOn()
}
}
fun tellHere() = updates.tellHere()
/** The app is in front again, perhaps back from allowing installs. */
fun resumed() {
updates.lookForReady()
carryOn()
}
fun update() {
val now = state.value
when {
now.offer == null || now.stage != InstallStage.IDLE -> Unit
!updates.mayInstall() -> {
saved[ASKED] = true
updates.askToInstall()
}
else -> updates.fetchAndInstall()
}
}
private fun carryOn() {
val now = state.value
val next = afterAsking(
asked = saved.get<Boolean>(ASKED) == true,
mayInstall = updates.mayInstall(),
offered = now.offer != null,
busy = now.stage != InstallStage.IDLE,
)
when (next) {
AfterAsking.WAIT -> Unit
AfterAsking.REFUSED -> {
saved[ASKED] = false
updates.refused()
}
AfterAsking.FETCH -> {
saved[ASKED] = false
updates.fetchAndInstall()
}
}
}
private companion object {
/** The listener asked for the update and was sent to allow installs. */
const val ASKED = "update.asked_to_allow"
}
}
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
class AfterAskingTest {
@Test
fun `allowed now, the update carries on`() {
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
}
@Test
fun `back without allowing it, the listener is told and the offer stays`() {
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
}
@Test
fun `nothing asked, nothing happens`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
}
@Test
fun `an offer not known yet, or an install under way, waits`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
}
}
@@ -0,0 +1,120 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import retrofit2.HttpException
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The fetch worker and the listener's Update both download through here; a
* package only counts as ready once it is the whole build that was offered.
*/
class UpdateRepositoryTest {
@TempDir
lateinit var dir: File
private val apkBytes = "the newer app".toByteArray()
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
version = version,
code = code,
sizeBytes = apkBytes.size.toLong(),
sha256 = sha256,
)
private var downloads = 0
private fun repo(
version: () -> UpdateInfoWire = { wire() },
body: () -> ByteArray = { apkBytes },
offers: Boolean = true,
) = UpdateRepository(
api = object : ClientVersionApi {
override suspend fun getVersion(): UpdateInfoWire = version()
},
apk = ApkSource {
downloads += 1
body().toResponseBody()
},
dir = dir,
installedCode = 100L,
installedName = "2026.10.20.0900",
offers = offers,
)
@Test
fun `a newer build is offered`() = runTest {
assertIs<Offer.Newer>(repo().check())
}
@Test
fun `the same app built again at a later minute is not offered`() = runTest {
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
}
@Test
fun `a server with no app to offer is not a problem`() = runTest {
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
assertEquals(Offer.Current, none.check())
}
@Test
fun `a server out of reach is a problem, not an all-clear`() = runTest {
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
}
@Test
fun `a debug build is never offered one`() = runTest {
assertEquals(Offer.Current, repo(offers = false).check())
}
@Test
fun `a build already fetched is installed from disk without a second download`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
assertIs<Download.Ready>(r.download(info) {})
assertNotNull(r.ready(info))
val again = r.download(info) {}
assertIs<Download.Ready>(again)
assertEquals(1, downloads)
}
@Test
fun `a download cut off partway is not taken for the build`() = runTest {
val r = repo(body = { apkBytes.copyOf(5) })
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
}
@Test
fun `a download that does not match the digest is not taken for the build`() = runTest {
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
}
@Test
fun `tidy drops a package kept for a build no longer offered`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
r.download(info) {}
r.tidy()
assertNull(r.ready(info))
}
}
@@ -0,0 +1,53 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Home tells of each build at most once a day, and says whether it is ready. */
class UpdateToldTest {
private val day = SAY_AGAIN_AFTER_MS
private val t0 = 1_000_000_000L
@Test
fun `a build not yet told of is told at once`() {
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
}
@Test
fun `the same build is not told again within a day`() {
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
}
@Test
fun `the same build is told again the day after`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
}
@Test
fun `a clock set back tells rather than hiding the update`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
}
@Test
fun `a build is keyed by its code, or by its name from a server with none`() {
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
}
@Test
fun `the snackbar says whether the update is already here`() {
assertEquals(
"Version 2026.10.26.1200 of the app is on your server.",
toldWords("2026.10.26.1200", ready = false),
)
assertEquals(
"Version 2026.10.26.1200 of the app is downloaded and ready.",
toldWords("2026.10.26.1200", ready = true),
)
assertEquals("Update", toldAction(ready = false))
assertEquals("Install", toldAction(ready = true))
}
}
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
) )
} }
/**
* Every server image builds the app again at a later minute, so a CI
* re-run, or a change outside the shipped set, raises the code while the
* app is the same one. The name says so, and it is not offered.
*/
@Test
fun `the same app built again at a later minute is not offered`() {
assertFalse(
isUpdateAvailable(
serverCode = 3523900, serverName = "2026.09.10.1432",
installedCode = 3523847, installedName = "2026.09.10.1432",
),
)
}
@Test @Test
fun `falls back to the name when the server reports no ordering key`() { fun `falls back to the name when the server reports no ordering key`() {
assertTrue( assertTrue(
+50
View File
@@ -17,8 +17,11 @@ package api
// client treats 404 as "no update channel available." // client treats 404 as "no update channel available."
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -62,6 +65,41 @@ var (
clientAPKLastDownloadMu sync.Mutex clientAPKLastDownloadMu sync.Mutex
) )
// clientAPKSum is the bundled APK's sha256, kept until the file changes
// under it (another size or modification time), so a version check every
// few minutes from every phone does not re-read tens of megabytes each time.
type clientAPKSum struct {
size int64
mod time.Time
hex string
}
var (
clientAPKSumCache clientAPKSum
clientAPKSumCacheMu sync.Mutex
)
// clientAPKDigest returns the sha256 of the APK at path, in hex.
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
clientAPKSumCacheMu.Lock()
defer clientAPKSumCacheMu.Unlock()
c := clientAPKSumCache
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
return c.hex, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
return clientAPKSumCache.hex, nil
}
func testResetClientAPKRateLimit() { func testResetClientAPKRateLimit() {
clientAPKLastDownloadMu.Lock() clientAPKLastDownloadMu.Lock()
defer clientAPKLastDownloadMu.Unlock() defer clientAPKLastDownloadMu.Unlock()
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
APKURL string `json:"apk_url"` APKURL string `json:"apk_url"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
// SHA256 is worked out from the file being served, not copied from the
// sidecar, so it cannot disagree with the bytes the phone receives. The
// app downloads ahead on Wi-Fi and installs later; this is how it knows
// the file it kept is the whole build that was offered.
SHA256 string `json:"sha256"`
} }
// handleClientVersion returns the bundled Android client version + a // handleClientVersion returns the bundled Android client version + a
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
return return
} }
sum, err := clientAPKDigest(apkPath, stat)
if err != nil {
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
return
}
writeJSON(w, http.StatusOK, clientVersionResponse{ writeJSON(w, http.StatusOK, clientVersionResponse{
Version: strings.TrimSpace(sidecar.Name), Version: strings.TrimSpace(sidecar.Name),
Code: sidecar.Code, Code: sidecar.Code,
Channel: strings.TrimSpace(sidecar.Channel), Channel: strings.TrimSpace(sidecar.Channel),
APKURL: "/api/client/apk", APKURL: "/api/client/apk",
SizeBytes: stat.Size(), SizeBytes: stat.Size(),
SHA256: sum,
}) })
} }
+35
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
if resp.SizeBytes != size { if resp.SizeBytes != size {
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes) t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
} }
want := sha256.Sum256([]byte("fake apk content"))
if resp.SHA256 != hex.EncodeToString(want[:]) {
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
}
}
// The digest is cached by size and modification time; a new APK dropped in
// place must be answered with its own digest, not the old one's.
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
writeClientAssets(t, `{"name":"a","code":1}`)
first := decodeClientVersion(t, getClientVersion(t))
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
apk := filepath.Join(dir, clientAPKFilename)
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
t.Fatal(err)
}
second := decodeClientVersion(t, getClientVersion(t))
want := sha256.Sum256([]byte("another, longer build"))
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
}
}
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
t.Helper()
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp clientVersionResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
} }
// A release published before ordering keys were recorded has a name and // A release published before ordering keys were recorded has a name and