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
@@ -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)
}
}