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 c8f4cf08..363bd7ec 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 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 f78a3fea..86e105f1 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 @@ -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() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt new file mode 100644 index 00000000..50e68480 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt new file mode 100644 index 00000000..3ab9087d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt @@ -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 = 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 + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt new file mode 100644 index 00000000..b3bbb724 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldBanner.kt @@ -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 = 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, + ) + } + } + } +}