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.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -110,6 +111,14 @@ class MinstrelApplication :
|
||||
*/
|
||||
@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
|
||||
* 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.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||
import com.fabledsword.minstrel.update.ui.UpdateBanner
|
||||
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
|
||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||
|
||||
@@ -62,10 +63,12 @@ fun ShellScaffold(
|
||||
}
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// Banner slot. VersionTooOldBanner appears when the server's
|
||||
// min_client_version exceeds our build; ConnectionErrorBanner
|
||||
// when the device loses usable internet. UpdateBanner will
|
||||
// join this slot once #25 ships.
|
||||
// min_client_version exceeds our build; UpdateBanner softly
|
||||
// nudges an available (newer-than-installed) APK; and
|
||||
// ConnectionErrorBanner shows when the device loses usable
|
||||
// internet.
|
||||
VersionTooOldBanner()
|
||||
UpdateBanner()
|
||||
ConnectionErrorBanner()
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
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