feat(android): soft "update available" shell banner (#25)
Ports Flutter's UpdateBanner. UpdateBannerController polls
/api/client/version at launch + every 24h, compares the bundled APK
against this build via isVersionNewer, and exposes the available
UpdateInfo (minus in-memory per-version dismissals). The shell banner
nudges "Update Minstrel · {version} available" with Install (reusing
ApkInstaller's download + system-install handoff, routing to the
install-permission settings first when needed) and a dismiss X. Uses an
understated surfaceVariant tone, distinct from the error-colored
VersionTooOldBanner hard gate.
Divergence noted: ApkInstaller exposes no byte progress, so the
downloading state shows an indeterminate bar rather than Flutter's
determinate one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.CoverPrefetcher
|
|||||||
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.update.data.VersionCheckController
|
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -110,6 +111,14 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
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
|
||||||
|
|
||||||
@@ -62,10 +63,12 @@ fun ShellScaffold(
|
|||||||
}
|
}
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
// Banner slot. VersionTooOldBanner appears when the server's
|
// Banner slot. VersionTooOldBanner appears when the server's
|
||||||
// min_client_version exceeds our build; ConnectionErrorBanner
|
// min_client_version exceeds our build; UpdateBanner softly
|
||||||
// when the device loses usable internet. UpdateBanner will
|
// nudges an available (newer-than-installed) APK; and
|
||||||
// join this slot once #25 ships.
|
// ConnectionErrorBanner shows when the device loses usable
|
||||||
|
// internet.
|
||||||
VersionTooOldBanner()
|
VersionTooOldBanner()
|
||||||
|
UpdateBanner()
|
||||||
ConnectionErrorBanner()
|
ConnectionErrorBanner()
|
||||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
content()
|
content()
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the shell's soft "update available" banner. Polls
|
||||||
|
* `/api/client/version` at launch + every 24h and, when the bundled
|
||||||
|
* APK is strictly newer than this build, exposes its [UpdateInfo] so
|
||||||
|
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
|
||||||
|
* install. Mirrors Flutter's `ClientUpdateController`.
|
||||||
|
*
|
||||||
|
* Dismissals are kept in memory and keyed by version, so dismissing
|
||||||
|
* one release's banner still lets a later release re-surface — and a
|
||||||
|
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||||
|
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||||
|
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class UpdateBannerController @Inject constructor(
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
private val repository: UpdateRepository,
|
||||||
|
) {
|
||||||
|
private val latest = MutableStateFlow<UpdateInfo?>(null)
|
||||||
|
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/** The available update, or null when none / dismissed / unreachable. */
|
||||||
|
val available: StateFlow<UpdateInfo?> =
|
||||||
|
combine(latest, dismissed) { info, seen ->
|
||||||
|
info?.takeIf { it.version !in seen }
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
runOnce()
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss(version: String) {
|
||||||
|
dismissed.value = dismissed.value + version
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runOnce() {
|
||||||
|
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
|
||||||
|
latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.fabledsword.minstrel.update.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composables.icons.lucide.Download
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell-level soft banner that nudges an available update. Renders
|
||||||
|
* nothing until [UpdateBannerController] surfaces a newer bundled APK
|
||||||
|
* (and the user hasn't dismissed that version). Ports Flutter's
|
||||||
|
* `UpdateBanner`: a download glyph, "Update Minstrel · {version}
|
||||||
|
* available", an Install button (download → system installer), and a
|
||||||
|
* dismiss X. Uses an understated surface tone, not the alarming
|
||||||
|
* error color reserved for `VersionTooOldBanner`.
|
||||||
|
*
|
||||||
|
* Divergence: [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
|
||||||
|
* exposes no byte progress, so the downloading state is an
|
||||||
|
* indeterminate bar rather than Flutter's determinate one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val info = state.info
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = info != null,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
// info can flip to null during the exit animation; hold the last
|
||||||
|
// non-null so the collapsing banner keeps its content.
|
||||||
|
val shown = info ?: return@AnimatedVisibility
|
||||||
|
BannerBody(
|
||||||
|
info = shown,
|
||||||
|
stage = state.stage,
|
||||||
|
message = state.message,
|
||||||
|
onInstall = { viewModel.install(shown) },
|
||||||
|
onDismiss = { viewModel.dismiss(shown.version) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerBody(
|
||||||
|
info: UpdateInfo,
|
||||||
|
stage: InstallStage,
|
||||||
|
message: String?,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||||
|
if (stage == InstallStage.DOWNLOADING) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerRow(
|
||||||
|
info: UpdateInfo,
|
||||||
|
stage: InstallStage,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Download,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (stage == InstallStage.ERROR) {
|
||||||
|
"Update failed"
|
||||||
|
} else {
|
||||||
|
"Update Minstrel · ${info.version} available"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
||||||
|
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.X,
|
||||||
|
contentDescription = "Dismiss",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.fabledsword.minstrel.update.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||||
|
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
/** Install lifecycle for the banner's Install button. */
|
||||||
|
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
|
||||||
|
|
||||||
|
data class UpdateBannerUiState(
|
||||||
|
val info: UpdateInfo? = null,
|
||||||
|
val stage: InstallStage = InstallStage.IDLE,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||||
|
* and runs the download → system-install handoff via [ApkInstaller],
|
||||||
|
* mirroring the About card's flow (route to "install unknown apps"
|
||||||
|
* settings first when the permission is missing).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class UpdateBannerViewModel @Inject constructor(
|
||||||
|
private val controller: UpdateBannerController,
|
||||||
|
private val installer: ApkInstaller,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val installState = MutableStateFlow(IdleInstall)
|
||||||
|
|
||||||
|
val uiState: StateFlow<UpdateBannerUiState> =
|
||||||
|
combine(controller.available, installState) { info, install ->
|
||||||
|
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = UpdateBannerUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun dismiss(version: String) = controller.dismiss(version)
|
||||||
|
|
||||||
|
fun install(info: UpdateInfo) {
|
||||||
|
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
||||||
|
if (!installer.canInstall()) {
|
||||||
|
installer.requestInstallPermission()
|
||||||
|
installState.value = InstallSnapshot(
|
||||||
|
InstallStage.IDLE,
|
||||||
|
"Allow installs for Minstrel, then tap Install again.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||||
|
runCatching { installer.downloadApk(info.apkUrl) }
|
||||||
|
.onSuccess { apk ->
|
||||||
|
installer.launchInstall(apk)
|
||||||
|
installState.value = IdleInstall
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
installState.value = InstallSnapshot(
|
||||||
|
InstallStage.ERROR,
|
||||||
|
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||||
|
|
||||||
|
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
||||||
Reference in New Issue
Block a user