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
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:
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+191
-19
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user