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:
@@ -14,6 +14,7 @@ import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
|||||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
|
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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -79,6 +80,14 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var coverPrefetcher: CoverPrefetcher
|
@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
|
* Same construct-the-singleton trick — EventsStream's init block
|
||||||
* subscribes to AuthStore.sessionCookie and opens / closes the
|
* 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.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.update.ui.VersionTooOldBanner
|
||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,9 +54,11 @@ fun ShellScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
// Banner slot. ConnectionErrorBanner auto-shows when the device
|
// Banner slot. VersionTooOldBanner appears when the server's
|
||||||
// loses usable internet. VersionTooOld / UpdateBanner will join
|
// min_client_version exceeds our build; ConnectionErrorBanner
|
||||||
// this slot once those mechanisms exist.
|
// when the device loses usable internet. UpdateBanner will
|
||||||
|
// join this slot once #25 ships.
|
||||||
|
VersionTooOldBanner()
|
||||||
ConnectionErrorBanner()
|
ConnectionErrorBanner()
|
||||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
content()
|
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 = "",
|
||||||
|
)
|
||||||
+67
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user