feat(android): VersionGate banner (audit v2 #23)

* HealthzApi — GET /healthz unauthenticated endpoint returning
  {status, version, min_client_version}.
* VersionCheckController — Hilt singleton; 5-minute poll loop on
  appScope, soft-fails on network errors (keeps last-known
  VersionResult: OK / TOO_OLD / SKIPPED). Reuses isVersionNewer()
  from the About card's UpdateRepository. Exposes recheck() for
  the banner's "Check now" button.
* VersionTooOldBanner — shell-level Compose banner with
  AnimatedVisibility shrink/expand, triangle-alert icon, copy
  matching Flutter, "Check now" trailing button. Tiny
  VersionTooOldViewModel lifts the StateFlow through Hilt.
* ShellScaffold adds VersionTooOldBanner() above ConnectionErrorBanner()
  in the existing banner slot.
* MinstrelApplication uses the construct-the-singleton trick to
  start the poll loop at app launch.

Closes audit v2 #23. Locally cached content keeps working when
the banner is shown — the message nudges the user toward an
update without blocking the rest of the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 18:47:00 -04:00
parent d20fab5459
commit ae26d66987
5 changed files with 199 additions and 3 deletions
@@ -14,6 +14,7 @@ import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -79,6 +80,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
* shell-level VersionTooOldBanner can surface min_client_version
* mismatches without waiting for the next user-driven request.
*/
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
/**
* Same construct-the-singleton trick — EventsStream's init block
* subscribes to AuthStore.sessionCookie and opens / closes the
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
/**
@@ -53,9 +54,11 @@ fun ShellScaffold(
}
}
Column(modifier = modifier.fillMaxSize()) {
// Banner slot. ConnectionErrorBanner auto-shows when the device
// loses usable internet. VersionTooOld / UpdateBanner will join
// this slot once those mechanisms exist.
// 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.
VersionTooOldBanner()
ConnectionErrorBanner()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
content()
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.update.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
* to surface the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
suspend fun check(): HealthzResponse
}
/**
* Wire shape for /healthz. `minClientVersion` may be empty on older
* servers; the caller treats empty as "skip" rather than "fail" so a
* partial deploy doesn't gate the UI.
*/
@Serializable
data class HealthzResponse(
val status: String = "",
val version: String = "",
@SerialName("min_client_version") val minClientVersion: String = "",
)
@@ -0,0 +1,67 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val response = runCatching { api.check() }.getOrNull() ?: return
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,89 @@
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
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.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.TriangleAlert
import com.fabledsword.minstrel.update.data.VersionCheckController
import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Tiny VM that exposes the [VersionCheckController]'s current
* [VersionResult] plus its recheck trigger for the banner button.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result
fun recheck() = controller.recheck()
}
/**
* Shell-level banner shown when the server reports `min_client_version`
* newer than our build. Non-blocking — locally cached content keeps
* working; the banner is the nudge to install the next APK. "Check
* now" forces a fresh /healthz probe.
*/
@Composable
fun VersionTooOldBanner(viewModel: VersionTooOldViewModel = hiltViewModel()) {
val result by viewModel.result.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = result == VersionResult.TOO_OLD,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Lucide.TriangleAlert,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "This app is older than the server requires. " +
"Cached content still works; install an update when ready.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { viewModel.recheck() }) {
Text(
text = "Check now",
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}