From fa75bd08d74e053d65e4e864019dcfa33e0c8e80 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 26 Oct 2026 07:46:01 -0400 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../minstrel/MinstrelApplication.kt | 9 - .../minstrel/home/ui/HomeScreen.kt | 10 +- .../fabledsword/minstrel/models/UpdateInfo.kt | 5 +- .../minstrel/models/wire/UpdateInfoWire.kt | 1 + .../minstrel/settings/ui/SettingsScreen.kt | 118 +--------- .../minstrel/settings/ui/StorageCard.kt | 2 +- .../minstrel/shared/widgets/ShellScaffold.kt | 11 +- .../minstrel/update/data/ApkInstaller.kt | 33 +-- .../minstrel/update/data/AppUpdates.kt | 196 ++++++++++++++++ .../minstrel/update/data/InstallOutcome.kt | 31 +-- .../minstrel/update/data/UpdateFetchWorker.kt | 84 +++++++ .../minstrel/update/data/UpdateModule.kt | 55 +++++ .../minstrel/update/data/UpdateRepository.kt | 210 ++++++++++++++++-- .../minstrel/update/data/UpdateTold.kt | 58 +++++ .../minstrel/update/ui/AppVersionCard.kt | 110 +++++++++ .../minstrel/update/ui/UpdateNotices.kt | 88 ++++++++ .../minstrel/update/ui/UpdateViewModel.kt | 86 +++++++ .../minstrel/update/data/AfterAskingTest.kt | 28 +++ .../update/data/UpdateRepositoryTest.kt | 120 ++++++++++ .../minstrel/update/data/UpdateToldTest.kt | 53 +++++ .../update/data/UpdateVersioningTest.kt | 15 ++ internal/api/client_assets.go | 50 +++++ internal/api/client_assets_test.go | 35 +++ 23 files changed, 1208 insertions(+), 200 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/AppUpdates.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateFetchWorker.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateModule.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateTold.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateNotices.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateViewModel.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/update/data/AfterAskingTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateRepositoryTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateToldTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 7cb6116f..8d596289 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -24,7 +24,6 @@ import com.fabledsword.minstrel.player.LikedAudioPinner import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.ResumeController -import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.connectivity.NetworkStatusController import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -155,14 +154,6 @@ class MinstrelApplication : */ @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 * block starts a 30-min sweep loop that re-fetches cached diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 511b2888..db1fcf4b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile 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 kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel @@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor( fun HomeScreen( navController: NavHostController, viewModel: HomeViewModel = hiltViewModel(), + updates: UpdateViewModel = hiltViewModel(), ) { val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { @@ -575,7 +578,11 @@ fun HomeScreen( val offline by viewModel.offline.collectAsStateWithLifecycle() val updating by viewModel.isUpdating.collectAsStateWithLifecycle() 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), ) { Box(Modifier.fillMaxSize()) { @@ -590,6 +597,7 @@ fun HomeScreen( // hide behind an "Updating your mixes…" wipe that stays up // until the screen has actually stopped moving. UpdatingVeil(visible = updating) + UpdateNotices(updates) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt index 5e3ace0b..ddb43069 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt @@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models * verbatim rather than validated, so an unexpected value is shown rather * 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( val version: String, @@ -23,4 +25,5 @@ data class UpdateInfo( val channel: String?, val apkUrl: String, val sizeBytes: Long, + val sha256: String, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt index bf24db3a..d9aec418 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/UpdateInfoWire.kt @@ -26,4 +26,5 @@ data class UpdateInfoWire( val channel: String? = null, @SerialName("apk_url") val apkUrl: String = "/api/client/apk", @SerialName("size_bytes") val sizeBytes: Long = 0, + val sha256: String = "", ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index ec4b13b0..349f9b6c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -51,7 +51,6 @@ import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Shield -import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.NotificationSettings 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.theme.ThemeMode import com.fabledsword.minstrel.theme.ThemePreferenceViewModel -import com.fabledsword.minstrel.update.data.InstallStage -import com.fabledsword.minstrel.update.data.isBusy +import com.fabledsword.minstrel.update.ui.AppVersionCard +import com.fabledsword.minstrel.update.ui.CheckWhileShown +import com.fabledsword.minstrel.update.ui.UpdateViewModel @Composable fun SettingsScreen( @@ -367,112 +367,14 @@ private fun displayName(mode: ThemeMode): String = when (mode) { 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 -private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { - val state by viewModel.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 ${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, - ) +private fun AboutCard(model: UpdateViewModel = hiltViewModel()) { + CheckWhileShown(model) + AppVersionCard(model) } @Composable diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt index e18c689b..3cc572a7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt @@ -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" return when { n < KIB -> "$n B" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index 6d6327e5..127a2bbf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.player.ui.MiniPlayer 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.shared.widgets.trackactions.TrackActionsViewModel @@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0) * [onExpandPlayer] is the navigate-to-NowPlaying callback passed * through to MiniPlayer's tap-handler. No NavHostController parameter * 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. */ @Composable @@ -97,12 +96,10 @@ fun ShellScaffold( // the status bar — no double inset, and no banner drawing under it. Column(modifier = modifier.fillMaxSize().statusBarsPadding()) { // Banner slot. VersionTooOldBanner appears when the server's - // min_client_version exceeds our build; UpdateBanner softly - // nudges an available (newer-than-installed) APK; and - // ConnectionErrorBanner shows when the device loses usable - // internet. + // min_client_version exceeds our build, and ConnectionErrorBanner + // shows when the device loses usable internet. A newer app is not + // a banner: Home tells of it in a snackbar (UpdateNotices). VersionTooOldBanner() - UpdateBanner() ConnectionErrorBanner() // The window is edge-to-edge, so something has to own the // bottom navigation-bar inset, and the shell claims it once diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt index 740abe99..405cbbe4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/ApkInstaller.kt @@ -6,24 +6,13 @@ import android.net.Uri import android.os.Build import android.provider.Settings 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.IOException import javax.inject.Inject import javax.inject.Singleton -private const val APK_FILENAME = "minstrel-update.apk" - /** - * Downloads the server-bundled APK and installs it over ourselves. - * - * 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 + * Installs a downloaded APK over ourselves. [UpdateRepository] brings it + * down; [SelfUpdateSession] streams it from there into a * [android.content.pm.PackageInstaller] session. * * On Android O+ the user must have granted "install unknown apps" @@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk" @Singleton class ApkInstaller @Inject constructor( @ApplicationContext private val context: Context, - private val okHttpClient: OkHttpClient, 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. */ fun canInstall(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.O || diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/AppUpdates.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/AppUpdates.kt new file mode 100644 index 00000000..40d79c4a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/AppUpdates.kt @@ -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 = mutable.asStateFlow() + + private val telling = Channel(Channel.BUFFERED) + + /** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */ + val news: Flow = 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 + } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/InstallOutcome.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/InstallOutcome.kt index ef6e6dcd..12718958 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/InstallOutcome.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/InstallOutcome.kt @@ -26,37 +26,22 @@ sealed interface InstallOutcome { } /** - * Where an install has got to, for the two surfaces that show it: the shell's - * [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the - * Settings About card. + * Where an install has got to, for the card that shows it (in Settings and + * in the sheet Home's update news opens). * * DOWNLOADING and INSTALLING are deliberately distinct: the install awaits * the platform's verdict and can sit on a confirm dialog indefinitely, so * 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 } - -/** 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 -} +enum class InstallStage { IDLE, DOWNLOADING, INSTALLING } /** * User-facing copy for an outcome; null when there is nothing worth saying. - * - * Lives beside the outcome rather than in either UI package because two - * separate screens surface the same verdicts and must not drift — the same - * reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] - * outside the UI layer. + * Lives beside the outcome rather than in the UI package, the same reasoning + * that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the + * UI layer. */ fun InstallOutcome.message(): String? = when (this) { InstallOutcome.Installed -> null diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateFetchWorker.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateFetchWorker.kt new file mode 100644 index 00000000..c43d90c5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateFetchWorker.kt @@ -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() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build(), + ) + .build(), + ) + } + + private companion object { + const val FETCHING = "update-fetch" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateModule.kt new file mode 100644 index 00000000..cb2455a8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateModule.kt @@ -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"), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt index 4dbd9b24..b66d91ff 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateRepository.kt @@ -1,21 +1,158 @@ package com.fabledsword.minstrel.update.data +import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.wire.UpdateInfoWire import com.fabledsword.minstrel.update.api.ClientVersionApi -import retrofit2.Retrofit -import javax.inject.Inject -import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +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 - * Check for updates button and by [UpdateBannerController]. + * Whether the server offers a newer app than this one, and fetching it. + * + * 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 @Inject constructor(retrofit: Retrofit) { - private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java) +class UpdateRepository( + private val api: ClientVersionApi, + 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( @@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo( channel = channel, apkUrl = apkUrl, 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 - * same value Android's package installer compares, so an offer made this way - * implies an install the platform will actually accept. Comparing NAMES - * 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. + * **May it be installed over this one** is the ordering key's, whenever the + * server sends one: it is the same value Android's package installer + * compares, so an offer made on it is an install the platform will take. * - * Name comparison survives only as the fallback for a server that predates - * the field. A null code means "this server cannot tell me" — never "zero" — + * **Is it another app at all** is the name's, compared for equality only. + * 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 * old and offer its build to everyone, forever. */ @@ -48,7 +220,7 @@ fun isUpdateAvailable( installedName: String, ): Boolean = if (serverCode != null) { - serverCode > installedCode + serverCode > installedCode && serverName != installedName } else { isVersionNewer(serverName, installedName) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateTold.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateTold.kt new file mode 100644 index 00000000..a03990b7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateTold.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt new file mode 100644 index 00000000..1dee8f39 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt @@ -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)}" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateNotices.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateNotices.kt new file mode 100644 index 00000000..7d5867c9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateNotices.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateViewModel.kt new file mode 100644 index 00000000..5fceef0a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateViewModel.kt @@ -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 = updates.state + val news: Flow = 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(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" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/update/data/AfterAskingTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/update/data/AfterAskingTest.kt new file mode 100644 index 00000000..24cc613e --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/update/data/AfterAskingTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateRepositoryTest.kt new file mode 100644 index 00000000..1e295e79 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateRepositoryTest.kt @@ -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(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(404, "".toResponseBody())) }) + assertEquals(Offer.Current, none.check()) + } + + @Test + fun `a server out of reach is a problem, not an all-clear`() = runTest { + assertIs(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(r.download(info) {}) + assertNotNull(r.ready(info)) + val again = r.download(info) {} + assertIs(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(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(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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateToldTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateToldTest.kt new file mode 100644 index 00000000..226ce9ca --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateToldTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateVersioningTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateVersioningTest.kt index 130c8b6a..442222d2 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateVersioningTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/update/data/UpdateVersioningTest.kt @@ -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 fun `falls back to the name when the server reports no ordering key`() { assertTrue( diff --git a/internal/api/client_assets.go b/internal/api/client_assets.go index d35dd66d..0ce6f507 100644 --- a/internal/api/client_assets.go +++ b/internal/api/client_assets.go @@ -17,8 +17,11 @@ package api // client treats 404 as "no update channel available." import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "io" "net/http" "os" "path/filepath" @@ -62,6 +65,41 @@ var ( 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() { clientAPKLastDownloadMu.Lock() defer clientAPKLastDownloadMu.Unlock() @@ -115,6 +153,11 @@ type clientVersionResponse struct { Channel string `json:"channel,omitempty"` APKURL string `json:"apk_url"` 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 @@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) { return } + sum, err := clientAPKDigest(apkPath, stat) + if err != nil { + writeErrWithLog(w, h.logger, "client_version: digest apk", err) + return + } + writeJSON(w, http.StatusOK, clientVersionResponse{ Version: strings.TrimSpace(sidecar.Name), Code: sidecar.Code, Channel: strings.TrimSpace(sidecar.Channel), APKURL: "/api/client/apk", SizeBytes: stat.Size(), + SHA256: sum, }) } diff --git a/internal/api/client_assets_test.go b/internal/api/client_assets_test.go index fdd735ec..eb8a77a1 100644 --- a/internal/api/client_assets_test.go +++ b/internal/api/client_assets_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "io" "log/slog" @@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) { if resp.SizeBytes != size { 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