Server-controlled app updates, optional or forced

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.

- internal/appupdate holds the decision as pure, tested logic: below
  minimumVersion is mandatory, below latestVersion is optional.
- Admin page gains an App updates section — latest version, APK URL,
  notes, and a "Require this update" toggle that sets the forced floor.
- Client shows a dismissable prompt for optional, and a full-screen
  panel that swallows Back for mandatory. Instructions say what the
  system installer will ask before it asks.

Two safeguards: a client that cannot report its version is never forced,
and the client ignores a verdict with no download URL, so a
half-configured policy cannot produce an unblockable screen with a dead
button. An unreachable gateway shows nothing.

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:34:04 +12:00
co-authored by Claude Opus 5
parent 08360b75e4
commit 62f6345a40
19 changed files with 1016 additions and 11 deletions
@@ -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<HomeRow> = 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)
@@ -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(
@@ -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
@@ -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")
}
@@ -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<BaseItem>
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) =
@@ -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),
)
}
}
}
@@ -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<String?>(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,
)
}
}
@@ -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<GatewayUpdate>(
"""{"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<GatewayUpdate>(
"""{"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<GatewayUpdate>("""{"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<GatewayUpdate>(
"""{"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<GatewayUpdate>("{}")
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<GatewayUpdate>(
"""{"status":"nag","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
)
assertFalse(update.isMandatory)
assertFalse(update.isOptional)
}
}