diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index bf7cf59c..522f6e84 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index 7348b6e6..112b103a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -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() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt new file mode 100644 index 00000000..188bfff6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt @@ -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(null) + private val dismissed = MutableStateFlow>(emptySet()) + + /** The available update, or null when none / dismissed / unreachable. */ + val available: StateFlow = + 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) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt new file mode 100644 index 00000000..c9477595 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBanner.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt new file mode 100644 index 00000000..e48652a8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/UpdateBannerViewModel.kt @@ -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 = + 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)