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:
co-authored by
Claude Opus 5
parent
08360b75e4
commit
62f6345a40
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user