diff --git a/CLAUDE.md b/CLAUDE.md index 5552a5b..13dac8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,17 @@ key, so a changed key forces every user to uninstall and reinstall. URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`. +**Forced updates are server-controlled.** `server/internal/appupdate` decides `none` / +`optional` / `mandatory` from the client's `X-Memby-Version` header against an +operator-set policy (admin page → App updates). `HomeViewModel.checkForAppUpdate` runs on +every launch and `ui/UpdateScreen.kt` renders the verdict — mandatory covers the whole +home screen with `zIndex(10f)`, swallows Back and offers no dismiss. Two safeguards worth +preserving: a client with an unreadable version is never forced (it could not escape the +prompt), and the client ignores any verdict without a `downloadUrl` (`isActionable`), so a +half-configured policy cannot produce a blocking screen with a dead button. The verdict +must stay out of `/v1/home`, which is cached per user while this answer varies per client +build. + ## Architecture **Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore` diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 1cce3e8..47baea8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.model.GatewayLoginRequest import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayRowEvent import com.ponzischeme89.memby.data.model.GatewayRowEvents +import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.PlaybackReport import com.ponzischeme89.memby.data.remote.EmbyApi @@ -330,6 +331,18 @@ class EmbyRepository(private val settings: SettingsStore) { */ suspend fun getRecommendations(): List = requireGateway().recommendations().rows + /** + * The gateway's verdict on this build. Null on the direct path, where nobody is in a + * position to decide, and null on failure — an unreachable gateway must never leave + * a TV stuck behind a blocking update prompt. + */ + suspend fun checkAppUpdate(): GatewayUpdate? { + if (!ServerConfig.isGateway) return null + return runCatching { requireGateway().updateStatus() } + .getOrNull() + ?.takeIf { it.isActionable } + } + /** Full item metadata, requested only after focus settles on an item. */ suspend fun getItemDetails(itemId: String): BaseItem { if (ServerConfig.isGateway) return requireGateway().item(itemId) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 5f2db35..beddb4e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -57,6 +57,32 @@ data class GatewayHome( val partial: Boolean = false, ) +/** + * The gateway's verdict on this build, from `GET /v1/update`. + * + * [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the + * operator has decided this version may no longer be used. + */ +@Serializable +data class GatewayUpdate( + val status: String = STATUS_NONE, + val version: String = "", + val notes: String = "", + val downloadUrl: String = "", +) { + val isMandatory: Boolean get() = status == STATUS_MANDATORY + val isOptional: Boolean get() = status == STATUS_OPTIONAL + + /** Usable only if the gateway actually told us where the APK is. */ + val isActionable: Boolean get() = downloadUrl.isNotBlank() && status != STATUS_NONE + + companion object { + const val STATUS_NONE = "none" + const val STATUS_OPTIONAL = "optional" + const val STATUS_MANDATORY = "mandatory" + } +} + /** Response of `GET /v1/recommendations`. */ @Serializable data class GatewayRows( diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index dd61980..cfab15b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -10,6 +10,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRows +import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.UserItemData import retrofit2.http.Body import retrofit2.http.GET @@ -45,6 +46,13 @@ interface GatewayApi { @GET("v1/recommendations") suspend fun recommendations(): GatewayRows + /** + * Whether this build should update. The version travels as a header on every request + * (see GatewayServiceFactory), so there is nothing to pass here. + */ + @GET("v1/update") + suspend fun updateStatus(): GatewayUpdate + @GET("v1/items/{id}") suspend fun item(@Path("id") itemId: String): BaseItem diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 8fbcc8b..650e0a6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.data.remote import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.ponzischeme89.memby.BuildConfig import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -45,7 +46,11 @@ object GatewayServiceFactory { */ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val builder = chain.request().newBuilder().header("Accept", "application/json") + val builder = chain.request().newBuilder() + .header("Accept", "application/json") + // The gateway decides whether this build needs updating, so every request + // says which build it is. + .header("X-Memby-Version", BuildConfig.VERSION_NAME) tokenProvider()?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index b024f48..beccd50 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.isMaintenanceError import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayUpdate import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.UserItemData import kotlinx.coroutines.Dispatchers @@ -48,6 +49,11 @@ data class HomeUiState( * rows behind it would be stale and unusable anyway. */ val maintenanceMessage: String? = null, + /** + * The gateway's update verdict. A mandatory one blocks the home screen; an optional + * one shows a prompt the viewer can dismiss for this session. + */ + val update: GatewayUpdate? = null, ) { val watchingAndNextUp: List get() = (continueWatching + nextUp).distinctBy(BaseItem::id) @@ -91,8 +97,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { /** Row engagement, buffered here and uploaded in batches. */ private val analytics = RowAnalytics() + /** Optional prompt the viewer waved away; forgotten when the app restarts. */ + private var dismissedUpdateVersion: String? = null + init { refreshAll() + checkForAppUpdate() viewModelScope.launch { repository.playbackStops.collect { refreshWatching() } } @@ -106,6 +116,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } + /** + * Asks the gateway whether this build is still allowed. Runs on every launch, so an + * operator can retire a version without waiting for anyone to open Settings. + */ + fun checkForAppUpdate() { + viewModelScope.launch(Dispatchers.IO) { + val update = repository.checkAppUpdate() ?: return@launch + // A dismissed optional prompt stays dismissed for this session; a mandatory + // one always reasserts itself. + if (update.isOptional && dismissedUpdateVersion == update.version) return@launch + _state.update { it.copy(update = update) } + } + } + + /** Dismisses an optional prompt. Mandatory updates ignore this by construction. */ + fun dismissUpdatePrompt() { + val current = _state.value.update ?: return + if (current.isMandatory) return + dismissedUpdateVersion = current.version + _state.update { it.copy(update = null) } + } + fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind) fun trackRowFocused(rowId: String, rowKind: String, itemId: String) = diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index fa22782..8a82c50 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -656,6 +656,15 @@ private fun HomeScreen(settings: Settings) { }, ) } + // Last in the Box, so it draws over everything — including the rail and any + // overlay that happened to be open when the check came back. + homeState.update?.let { update -> + UpdateScreen( + update = update, + onDismiss = homeViewModel::dismissUpdatePrompt, + modifier = Modifier.zIndex(10f), + ) + } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt new file mode 100644 index 0000000..80a140a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -0,0 +1,304 @@ +package com.ponzischeme89.memby.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.ponzischeme89.memby.data.model.GatewayUpdate +import com.ponzischeme89.memby.update.UpdateChecker +import kotlinx.coroutines.launch + +private val UpdateAccent = Color(0xFF52B54B) +private val UpdateTitle = Color(0xFFF2F5F7) +private val UpdateBody = Color(0xFFAEB7BF) +private val UpdateFaint = Color(0xFF7E888F) + +/** + * The update prompt, shown over the home screen. + * + * A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and + * there is one button. The operator has decided this build may no longer be used, so + * leaving a way past it would defeat the point. An **optional** one is the same panel with + * a "Later" button. + * + * The instructions matter more than the visuals here: on a TV, the system installer looks + * like a permission prompt the viewer has never seen, so the copy says what will happen + * before it happens. + */ +@Composable +fun UpdateScreen( + update: GatewayUpdate, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val checker = remember { UpdateChecker(context) } + + var installing by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + val primaryFocus = remember { FocusRequester() } + LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } } + + // Swallow Back entirely while an update is required. For an optional prompt, Back is + // the natural "later". + BackHandler(enabled = true) { if (!update.isMandatory) onDismiss() } + + val transition = rememberInfiniteTransition(label = "update") + val drift by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(7_000, easing = LinearEasing), RepeatMode.Reverse), + label = "drift", + ) + val bob by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1_800, easing = LinearEasing), RepeatMode.Reverse), + label = "bob", + ) + + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + val entrance by animateFloatAsState( + targetValue = if (entered) 1f else 0f, + animationSpec = tween(360), + label = "update-entrance", + ) + + Box( + modifier = modifier + .fillMaxSize() + // Opaque, not a scrim: a required update is not a dialog over usable content. + .background(Color(0xFF0B0E11)) + // Consumes clicks so nothing behind can be reached. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) { + Canvas(Modifier.fillMaxSize()) { + val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f) + val radius = size.minDimension * 0.62f + drawCircle( + brush = Brush.radialGradient( + colors = listOf(UpdateAccent.copy(alpha = 0.14f), Color.Transparent), + center = centre, + radius = radius, + ), + radius = radius, + center = centre, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 60.dp) + .graphicsLayer { + alpha = entrance + translationY = (1f - entrance) * 22.dp.toPx() + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(92.dp) + .graphicsLayer { translationY = -6.dp.toPx() * bob } + .clip(CircleShape) + .background(Color(0xFF16202A)) + .border(1.dp, UpdateAccent.copy(alpha = 0.4f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = null, + tint = UpdateAccent, + modifier = Modifier.size(40.dp), + ) + } + + Spacer(Modifier.height(26.dp)) + Text( + if (update.isMandatory) "Time to update Memby" else "A new version of Memby is ready", + color = UpdateTitle, + fontSize = 32.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(10.dp)) + Text( + if (update.isMandatory) { + "Version ${update.version} is required to keep watching. It only takes a moment." + } else { + "Version ${update.version} is available." + }, + color = UpdateBody, + fontSize = 17.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 640.dp), + ) + + if (update.notes.isNotBlank()) { + Spacer(Modifier.height(14.dp)) + Text( + update.notes, + color = UpdateFaint, + fontSize = 15.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + + Spacer(Modifier.height(22.dp)) + Text( + // Said before it happens, because the system installer is unfamiliar and + // easy to back out of by accident. + "Choose Update now — Memby downloads the new version, then your TV asks you " + + "to confirm the install. If it asks permission to install apps, allow it and " + + "the update continues.", + color = UpdateFaint, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 600.dp), + ) + + Spacer(Modifier.height(30.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + UpdateButton( + label = if (installing) "Downloading…" else "Update now", + primary = true, + enabled = !installing, + onClick = { + if (installing) return@UpdateButton + installing = true + message = null + scope.launch { + val result = checker.downloadAndInstall(update.downloadUrl, token = "") + installing = false + message = result.exceptionOrNull()?.message + ?: "Opening the installer…" + } + }, + modifier = Modifier.focusRequester(primaryFocus), + ) + if (!update.isMandatory) { + UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss) + } + } + + message?.let { + Spacer(Modifier.height(18.dp)) + Text(it, color = UpdateFaint, fontSize = 14.sp, textAlign = TextAlign.Center) + } + + if (update.isMandatory) { + Spacer(Modifier.height(22.dp)) + Text( + "Stuck? Ask whoever set up Memby for you.", + color = UpdateFaint.copy(alpha = 0.75f), + fontSize = 13.sp, + ) + } + } + } +} + +@Composable +private fun UpdateButton( + label: String, + primary: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (focused) 1.06f else 1f, + animationSpec = tween(120), + label = "update-button-scale", + ) + + Box( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale; alpha = if (enabled) 1f else 0.6f } + .clip(RoundedCornerShape(10.dp)) + .background( + when { + focused && primary -> UpdateAccent + focused -> Color(0xFF2A343F) + primary -> Color(0xFF2C6A29) + else -> Color(0xFF1E2833) + }, + ) + .border( + width = if (focused) 0.dp else 1.dp, + color = Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(10.dp), + ) + .onFocusChanged { focused = it.isFocused } + .focusable(interactionSource = remember { MutableInteractionSource() }) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 30.dp, vertical = 13.dp), + ) { + Text( + label, + color = if (focused && primary) Color(0xFF06240A) else UpdateTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt new file mode 100644 index 0000000..b6f9b65 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayUpdateTest.kt @@ -0,0 +1,77 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.model.GatewayUpdate +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The update verdict decides whether a viewer can use the app at all, so the client's + * reading of it needs pinning down — including the cases that must NOT block anyone. + */ +class GatewayUpdateTest { + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + @Test + fun `a mandatory verdict is actionable and blocking`() { + val update = json.decodeFromString( + """{"status":"mandatory","version":"0.1.54","notes":"Security fix","downloadUrl":"https://nas/memby.apk"}""", + ) + + assertTrue(update.isMandatory) + assertFalse(update.isOptional) + assertTrue(update.isActionable) + assertEquals("0.1.54", update.version) + } + + @Test + fun `an optional verdict is dismissable`() { + val update = json.decodeFromString( + """{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""", + ) + + assertTrue(update.isOptional) + assertFalse(update.isMandatory) + assertTrue(update.isActionable) + } + + @Test + fun `status none is never shown`() { + val update = json.decodeFromString("""{"status":"none"}""") + + assertFalse(update.isActionable) + } + + @Test + fun `a verdict with no download url is not actionable`() { + // Nothing to act on: showing an unblockable prompt with no working button would + // strand the viewer completely. + val update = json.decodeFromString( + """{"status":"mandatory","version":"0.1.54","downloadUrl":""}""", + ) + + assertTrue(update.isMandatory) + assertFalse(update.isActionable) + } + + @Test + fun `an empty response decodes to nothing to do`() { + val update = json.decodeFromString("{}") + + assertEquals(GatewayUpdate.STATUS_NONE, update.status) + assertFalse(update.isActionable) + } + + @Test + fun `an unrecognised status from a newer gateway is treated as nothing`() { + val update = json.decodeFromString( + """{"status":"nag","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""", + ) + + assertFalse(update.isMandatory) + assertFalse(update.isOptional) + } +} diff --git a/server/README.md b/server/README.md index f3d7961..6a4ce08 100644 --- a/server/README.md +++ b/server/README.md @@ -48,6 +48,7 @@ headers attached. | GET | `/v1/auth/session` | Confirm a stored token is still valid | | GET | `/v1/home?limit=` | **Every launcher row in one response** | | GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild | +| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update | | GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request | | GET | `/v1/search?q=&limit=` | Library search | | GET | `/v1/items/{id}` | Full metadata for one item | @@ -131,6 +132,7 @@ gateway is reachable from outside the LAN. | GET | `/admin/api/status` | Library counts, sync history, maintenance state | | POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` | | POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` | +| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` | | GET | `/admin/api/analytics?days=7` | Row engagement | ## Library import @@ -172,6 +174,36 @@ The switch lives in Postgres, not memory, so a restart cannot quietly bring the up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it directly in the database works too. +## App update policy + +The gateway decides whether a TV may keep running its current build. Clients send +`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is +`none`, `optional` or `mandatory`. + +Set it on the admin page: **latest version**, **APK URL** (normally the same file the +landing page serves), release notes, and a **Require this update** toggle. + +- *Optional* — a dismissable prompt. Dismissal lasts for that session only. +- *Required* — a full-screen panel over the home screen with no way past it. Back is + swallowed and there is one button. Use it when a build is genuinely unusable, not for + ordinary releases. + +"Required" sets `minimumVersion = latestVersion`; anything below that floor is forced. +`minimumVersion` can also be set directly for a staged rollout where the forced floor is +older than the newest build. + +Two deliberate safeguards, both tested in `internal/appupdate`: + +- A client that cannot report a version is **never** forced. It would otherwise be stuck + behind a prompt it may have no way to satisfy. +- The client only shows a verdict carrying a download URL, so a misconfigured policy + cannot produce an unblockable screen with a dead button. An unreachable gateway shows + nothing at all. + +The verdict is deliberately *not* part of `/v1/home`: that payload is cached per user, +while this answer depends on the requesting client's version, so sharing a cache entry +would hand one TV's answer to another. + ## Row analytics The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 3b53be0..774731b 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -108,6 +108,11 @@ func run(log *slog.Logger) error { } go server.WatchMaintenance(ctx, 30*time.Second) + if err := server.LoadUpdatePolicy(ctx); err != nil { + return err + } + go server.WatchUpdatePolicy(ctx, 60*time.Second) + go syncer.Schedule(ctx, cfg.SyncInterval) if cfg.SyncOnStart { go func() { diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index df0276c..668cf4e 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -27,6 +28,7 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) + mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) return mux } @@ -60,11 +62,12 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { } type adminStatus struct { - Maintenance store.Maintenance `json:"maintenance"` - Library store.LibraryStats `json:"library"` - SyncRunning bool `json:"syncRunning"` - Runs []store.SyncRun `json:"runs"` - SyncEvery string `json:"syncEvery"` + Maintenance store.Maintenance `json:"maintenance"` + UpdatePolicy appupdate.Policy `json:"updatePolicy"` + Library store.LibraryStats `json:"library"` + SyncRunning bool `json:"syncRunning"` + Runs []store.SyncRun `json:"runs"` + SyncEvery string `json:"syncEvery"` } func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { @@ -84,14 +87,74 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, adminStatus{ - Maintenance: s.maintenance.get(), - Library: stats, - SyncRunning: s.syncer.Running(), - Runs: runs, - SyncEvery: s.cfg.SyncInterval.String(), + Maintenance: s.maintenance.get(), + UpdatePolicy: s.updatePolicy.get(), + Library: stats, + SyncRunning: s.syncer.Running(), + Runs: runs, + SyncEvery: s.cfg.SyncInterval.String(), }) } +type updatePolicyRequest struct { + Enabled bool `json:"enabled"` + LatestVersion string `json:"latestVersion"` + DownloadURL string `json:"downloadUrl"` + Notes string `json:"notes"` + // Required makes this release mandatory for everyone below it. The page offers a + // toggle rather than exposing "minimum version" directly, because "force this + // update" is the decision an operator actually wants to make. + Required bool `json:"required"` + // MinimumVersion is honoured when set explicitly, for staged rollouts where the + // forced floor is older than the latest build. + MinimumVersion string `json:"minimumVersion"` +} + +func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) { + var req updatePolicyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + + policy := appupdate.Policy{ + Enabled: req.Enabled, + LatestVersion: strings.TrimSpace(req.LatestVersion), + MinimumVersion: strings.TrimSpace(req.MinimumVersion), + DownloadURL: strings.TrimSpace(req.DownloadURL), + Notes: strings.TrimSpace(req.Notes), + } + if req.Required { + // Forcing means "nobody below the current build", so the floor is the latest. + policy.MinimumVersion = policy.LatestVersion + } else if policy.MinimumVersion == policy.LatestVersion { + // Un-ticking the box must actually release the floor. + policy.MinimumVersion = "" + } + + if policy.Enabled && policy.LatestVersion == "" { + writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts") + return + } + if policy.Enabled && policy.DownloadURL == "" { + writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts") + return + } + + if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil { + s.log.Error("update policy write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save the update policy") + return + } + if err := s.LoadUpdatePolicy(r.Context()); err != nil { + s.log.Warn("update policy reload failed", "error", err) + } + + s.log.Info("update policy changed", + "enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion) + writeJSON(w, http.StatusOK, s.updatePolicy.get()) +} + type syncRequest struct { Kind string `json:"kind"` } diff --git a/server/internal/api/admin.html b/server/internal/api/admin.html index 1d168df..82b9559 100644 --- a/server/internal/api/admin.html +++ b/server/internal/api/admin.html @@ -85,6 +85,29 @@ +
+

App updates

+

+ TVs check on every launch. Optional shows a dismissable prompt; + required blocks the home screen until the viewer updates. +

+
+ + +
+
+ +
+
+ + + + +
+
+

Row engagement

@@ -195,6 +218,29 @@ function renderStatus(status) { const messageField = document.getElementById('maintenance-message'); if (document.activeElement !== messageField) messageField.value = maintenance.message || ''; + const policy = status.updatePolicy || {}; + const policyState = document.getElementById('update-state'); + const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion; + if (!policy.enabled) { + policyState.textContent = 'off'; + policyState.className = 'pill muted'; + } else { + policyState.textContent = required ? 'REQUIRED ' + policy.latestVersion : 'optional ' + policy.latestVersion; + policyState.className = 'pill ' + (required ? 'warn' : 'ok'); + } + // Do not fight the operator for the field they are typing in. + const fields = { + 'update-version': policy.latestVersion || '', + 'update-url': policy.downloadUrl || '', + 'update-notes': policy.notes || '', + }; + for (const [id, value] of Object.entries(fields)) { + const el = document.getElementById(id); + if (document.activeElement !== el) el.value = value; + } + const requiredBox = document.getElementById('update-required'); + if (document.activeElement !== requiredBox) requiredBox.checked = required; + document.getElementById('runs').innerHTML = (status.runs || []).length ? status.runs.map((run) => { const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'; @@ -279,6 +325,25 @@ document.getElementById('maintenance-off').addEventListener('click', () => body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }), }))); +function updatePolicyBody(enabled) { + return JSON.stringify({ + enabled, + latestVersion: document.getElementById('update-version').value.trim(), + downloadUrl: document.getElementById('update-url').value.trim(), + notes: document.getElementById('update-notes').value.trim(), + required: document.getElementById('update-required').checked, + }); +} + +document.getElementById('update-save').addEventListener('click', () => { + if (document.getElementById('update-required').checked && + !confirm('Required updates block the home screen on every TV below this version. Continue?')) return; + act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(true) })); +}); + +document.getElementById('update-disable').addEventListener('click', () => + act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(false) }))); + document.getElementById('days').addEventListener('change', refresh); refresh(); diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 22ae0d1..212dcf1 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -38,6 +38,7 @@ type Server struct { recommendationBuilds recommendationBuilds maintenance maintenanceState + updatePolicy updatePolicyCache } // Deps are the collaborators the API needs. A struct rather than positional arguments: @@ -76,6 +77,7 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) v1.Handle("GET /v1/search", s.authed(s.handleSearch)) v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations)) + v1.Handle("GET /v1/update", s.authed(s.handleUpdate)) v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem)) v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) diff --git a/server/internal/api/home.go b/server/internal/api/home.go index f31f5bb..067c76f 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -43,6 +43,10 @@ type homeResponse struct { // Partial is true when at least one row failed upstream. The TV shows what arrived // and flags a refresh error rather than blanking the screen. Partial bool `json:"partial"` + + // Deliberately no update verdict here: this payload is cached per user, and the + // verdict depends on the *client's* version, so a cached body would hand one TV's + // answer to another running a different build. The client asks /v1/update instead. } // handleHome answers the entire launcher in one round trip. diff --git a/server/internal/api/update.go b/server/internal/api/update.go new file mode 100644 index 0000000..24df4c1 --- /dev/null +++ b/server/internal/api/update.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + "net/http" + "strings" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/appupdate" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// updatePolicyCache keeps the policy in memory. It is read on every home request, and a +// database round trip per home load to answer "nothing to say" would be wasteful. +type updatePolicyCache struct { + mu sync.RWMutex + value appupdate.Policy +} + +func (c *updatePolicyCache) get() appupdate.Policy { + c.mu.RLock() + defer c.mu.RUnlock() + return c.value +} + +func (c *updatePolicyCache) set(value appupdate.Policy) { + c.mu.Lock() + defer c.mu.Unlock() + c.value = value +} + +// LoadUpdatePolicy primes the cached policy. Called at boot and after every change. +func (s *Server) LoadUpdatePolicy(ctx context.Context) error { + policy, err := s.store.UpdatePolicy(ctx) + if err != nil { + return err + } + s.updatePolicy.set(policy) + return nil +} + +// WatchUpdatePolicy re-reads the policy periodically, so a change made directly in the +// database is picked up without a restart. +func (s *Server) WatchUpdatePolicy(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.LoadUpdatePolicy(ctx); err != nil { + s.log.Warn("update policy refresh failed", "error", err) + } + } + } +} + +// clientVersion reads the version a TV reports. Absent means an older build that predates +// the header — [appupdate.Decide] treats that as "say nothing". +func clientVersion(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-Memby-Version")) +} + +// handleUpdate answers the client's version check. +// +// Its own endpoint rather than a field on /v1/home: the home payload is cached per user, +// while this answer depends on the requesting client's version, so the two cannot share a +// cache entry. It costs nothing — the policy is held in memory. +func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ store.Session) { + decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r)) + writeJSON(w, http.StatusOK, decision) +} diff --git a/server/internal/appupdate/policy.go b/server/internal/appupdate/policy.go new file mode 100644 index 0000000..b5211ce --- /dev/null +++ b/server/internal/appupdate/policy.go @@ -0,0 +1,119 @@ +// Package appupdate decides whether a TV should be told to update, and how firmly. +// +// The gateway is the authority: the client reports the version it is running and renders +// whatever verdict comes back. That keeps "everyone must move to 0.1.54 now" a one-field +// change on the admin page rather than a release. +package appupdate + +import ( + "strconv" + "strings" + "time" +) + +// Status is how hard the TV should push the update. +const ( + // StatusNone — nothing to say; the client is current enough. + StatusNone = "none" + // StatusOptional — a newer build exists and the viewer may dismiss the prompt. + StatusOptional = "optional" + // StatusMandatory — the client is below the minimum supported version and must + // update before it can be used. + StatusMandatory = "mandatory" +) + +// Policy is the operator-controlled setting, stored in app_settings. +type Policy struct { + // Enabled turns the whole mechanism off without losing the values. + Enabled bool `json:"enabled"` + // LatestVersion is what clients below it are *offered*. + LatestVersion string `json:"latestVersion"` + // MinimumVersion is what clients below it are *forced* to. Leave blank (or equal to + // an old release) to keep updates optional; set it to LatestVersion to make the + // current release mandatory for everyone. + MinimumVersion string `json:"minimumVersion"` + // DownloadURL points at the APK — normally the same file the landing page serves. + DownloadURL string `json:"downloadUrl"` + Notes string `json:"notes"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Decision is what the client receives. +type Decision struct { + Status string `json:"status"` + Version string `json:"version"` + Notes string `json:"notes"` + DownloadURL string `json:"downloadUrl"` +} + +// Decide compares the version a client reported against the policy. +// +// An unreadable or absent client version yields StatusNone on purpose: a client that +// cannot say what it is would otherwise be locked out by a blocking prompt it may not +// even know how to satisfy. Those builds still have the manual check in Settings. +func Decide(policy Policy, clientVersion string) Decision { + none := Decision{Status: StatusNone} + + if !policy.Enabled || strings.TrimSpace(policy.LatestVersion) == "" { + return none + } + client := parseVersion(clientVersion) + if len(client) == 0 { + return none + } + + offer := Decision{ + Status: StatusOptional, + Version: normalize(policy.LatestVersion), + Notes: strings.TrimSpace(policy.Notes), + DownloadURL: strings.TrimSpace(policy.DownloadURL), + } + + if minimum := parseVersion(policy.MinimumVersion); len(minimum) > 0 && compare(client, minimum) < 0 { + offer.Status = StatusMandatory + return offer + } + if compare(client, parseVersion(policy.LatestVersion)) < 0 { + return offer + } + return none +} + +// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0. +func compare(a, b []int) int { + for i := 0; i < len(a) || i < len(b); i++ { + av, bv := 0, 0 + if i < len(a) { + av = a[i] + } + if i < len(b) { + bv = b[i] + } + if av != bv { + if av < bv { + return -1 + } + return 1 + } + } + return 0 +} + +// parseVersion mirrors the client's own parsing, so both sides agree on ordering. +func parseVersion(v string) []int { + parts := strings.FieldsFunc(normalize(v), func(r rune) bool { + return r == '.' || r == '-' || r == '+' || r == ' ' + }) + out := make([]int, 0, len(parts)) + for _, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + // Stop at the first non-numeric component ("0.1.54-beta" -> 0.1.54). + break + } + out = append(out, n) + } + return out +} + +func normalize(v string) string { return strings.TrimLeft(strings.TrimSpace(v), "vV") } diff --git a/server/internal/appupdate/policy_test.go b/server/internal/appupdate/policy_test.go new file mode 100644 index 0000000..a422f48 --- /dev/null +++ b/server/internal/appupdate/policy_test.go @@ -0,0 +1,118 @@ +package appupdate + +import "testing" + +func policy() Policy { + return Policy{ + Enabled: true, + LatestVersion: "0.1.54", + MinimumVersion: "0.1.50", + DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk", + Notes: "Faster home screen", + } +} + +func TestUpToDateClientIsLeftAlone(t *testing.T) { + for _, version := range []string{"0.1.54", "0.1.55", "0.2.0", "1.0.0"} { + if got := Decide(policy(), version).Status; got != StatusNone { + t.Fatalf("client %s: got %s, want none", version, got) + } + } +} + +func TestBehindLatestIsOptional(t *testing.T) { + decision := Decide(policy(), "0.1.53") + + if decision.Status != StatusOptional { + t.Fatalf("got %s, want optional", decision.Status) + } + if decision.Version != "0.1.54" || decision.DownloadURL == "" { + t.Fatalf("decision is missing what the TV needs to act: %+v", decision) + } +} + +func TestBelowMinimumIsMandatory(t *testing.T) { + if got := Decide(policy(), "0.1.49").Status; got != StatusMandatory { + t.Fatalf("got %s, want mandatory", got) + } +} + +// Setting minimum = latest is how an operator forces everyone onto the current build. +func TestMinimumEqualToLatestForcesEveryOldClient(t *testing.T) { + p := policy() + p.MinimumVersion = p.LatestVersion + + if got := Decide(p, "0.1.53").Status; got != StatusMandatory { + t.Fatalf("got %s, want mandatory", got) + } + if got := Decide(p, "0.1.54").Status; got != StatusNone { + t.Fatalf("a current client should still be left alone, got %s", got) + } +} + +func TestBlankMinimumKeepsUpdatesOptional(t *testing.T) { + p := policy() + p.MinimumVersion = "" + + if got := Decide(p, "0.0.1").Status; got != StatusOptional { + t.Fatalf("got %s, want optional", got) + } +} + +func TestDisabledPolicySaysNothing(t *testing.T) { + p := policy() + p.Enabled = false + + if got := Decide(p, "0.0.1").Status; got != StatusNone { + t.Fatalf("got %s, want none", got) + } +} + +func TestPolicyWithNoLatestVersionSaysNothing(t *testing.T) { + p := policy() + p.LatestVersion = " " + + if got := Decide(p, "0.0.1").Status; got != StatusNone { + t.Fatalf("got %s, want none", got) + } +} + +// A client that cannot report its version must not be hard-blocked: it would be stuck +// behind a prompt it may have no way to satisfy. +func TestUnknownClientVersionIsNeverForced(t *testing.T) { + p := policy() + p.MinimumVersion = p.LatestVersion + + for _, version := range []string{"", " ", "?", "unknown"} { + if got := Decide(p, version).Status; got != StatusNone { + t.Fatalf("client %q: got %s, want none", version, got) + } + } +} + +func TestVersionParsingIsForgiving(t *testing.T) { + p := policy() + + // Leading "v", pre-release suffixes and short versions all compare sensibly. + if got := Decide(p, "v0.1.53").Status; got != StatusOptional { + t.Fatalf("v-prefixed: got %s, want optional", got) + } + if got := Decide(p, "0.1.54-beta").Status; got != StatusNone { + t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got) + } + + p.LatestVersion = "0.2" + if got := Decide(p, "0.2.0").Status; got != StatusNone { + t.Fatalf("0.2.0 should equal 0.2, got %s", got) + } +} + +func TestMandatoryOutranksOptional(t *testing.T) { + p := policy() + p.MinimumVersion = "0.1.53" + + // Below both thresholds: the stronger verdict must win. + if got := Decide(p, "0.1.52").Status; got != StatusMandatory { + t.Fatalf("got %s, want mandatory", got) + } +} diff --git a/server/internal/store/settings.go b/server/internal/store/settings.go index 95c41a5..1f257ea 100644 --- a/server/internal/store/settings.go +++ b/server/internal/store/settings.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/ponzischeme89/memby/server/internal/appupdate" ) // MaintenanceKey is the app_settings row backing maintenance mode. @@ -60,6 +61,43 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error { return nil } +// UpdatePolicyKey is the app_settings row backing the client update policy. +const UpdatePolicyKey = "update_policy" + +func (s *Store) UpdatePolicy(ctx context.Context) (appupdate.Policy, error) { + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, UpdatePolicyKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return appupdate.Policy{}, nil + } + if err != nil { + return appupdate.Policy{}, fmt.Errorf("store: read update policy: %w", err) + } + + var policy appupdate.Policy + if err := json.Unmarshal(raw, &policy); err != nil { + return appupdate.Policy{}, fmt.Errorf("store: decode update policy: %w", err) + } + return policy, nil +} + +func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) error { + policy.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(policy) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_at) + VALUES ($1, $2::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + UpdatePolicyKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write update policy: %w", err) + } + return nil +} + // NewestSession is the fallback credential for the library import: whichever TV signed // in most recently. It means a fresh deployment can import without configuring a // service account, at the cost of the import stopping if that user is ever removed.