0.2.55 - Remote config/Request fixes

This commit is contained in:
ponzischeme89
2026-08-12 09:57:56 +12:00
parent 4b31946635
commit 9777eb0952
35 changed files with 1086 additions and 78 deletions
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
import com.ponzischeme89.memby.update.RequiredUpdateGuard
/**
@@ -25,6 +26,8 @@ object ServiceLocator {
private set
lateinit var maintenance: MaintenanceMonitor
private set
lateinit var remoteConfig: RemoteConfigManager
private set
/**
* Held rather than discarded because it is a long-lived collector, not a service
@@ -54,6 +57,9 @@ object ServiceLocator {
// Only hands the probe an application context; it does no work until the first
// request or playback negotiation asks what this television's receiver accepts.
installAudioCapabilityProbe(context)
// One tiny synchronous preference read selects an immutable process snapshot.
// Network revalidation is delayed and can only affect the next process.
remoteConfig = RemoteConfigManager(context.applicationContext)
settings = SettingsStore(context.applicationContext)
// Started before the repository, so a refusal answering the very first request a
// television makes has somewhere to be recorded.
@@ -69,5 +75,6 @@ object ServiceLocator {
// because the surfaces that obey it — the launcher, the player's Compose islands,
// the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme)
remoteConfig.refreshLater()
}
}
@@ -872,6 +872,7 @@ class EmbyRepository(private val settings: SettingsStore) {
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
mediaType = candidate.mediaType,
foreignId = candidate.foreignId,
title = candidate.title,
),
).title
}
@@ -446,6 +446,7 @@ data class GatewayRequestLookup(
data class GatewayMediaRequest(
val mediaType: String,
val foreignId: Int,
val title: String,
)
@Serializable
@@ -0,0 +1,264 @@
package com.ponzischeme89.memby.data.remoteconfig
import android.content.Context
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Request
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
private const val PREFERENCES_NAME = "memby_remote_config"
private const val DOCUMENT_KEY = "last_known_good"
/**
* Typed, process-stable presentation configuration.
*
* The active value is chosen once, before any Activity is created. A successful refresh
* only replaces the durable last-known-good document; it deliberately does not publish a
* Flow or mutable state that could repaint wording under somebody's focus.
*/
@Serializable
data class MembyRemoteConfig(
val schemaVersion: Int,
val configVersion: Long,
val minimumAppVersion: String = "",
val maximumAppVersion: String = "",
val copy: RemoteCopy,
val features: RemoteFeatures,
val presentation: RemotePresentation,
) {
val navigation: NavigationRemoteConfig
get() = NavigationRemoteConfig(
labels = copy.navigation,
tagline = copy.tagline,
showVersion = features.showNavigationVersion,
expandedWidthDp = presentation.navigationRailExpandedWidthDp,
contentShiftDp = presentation.navigationContentShiftDp,
)
}
@Serializable
data class RemoteCopy(
val navigation: NavigationLabels,
val tagline: String,
)
@Serializable
data class NavigationLabels(
val home: String,
val forYou: String,
val search: String,
val movies: String,
val tvShows: String,
val tvCalendar: String,
val favourites: String,
val user: String,
val settings: String,
)
@Serializable
data class RemoteFeatures(
val showNavigationVersion: Boolean,
)
@Serializable
data class RemotePresentation(
val navigationRailExpandedWidthDp: Int,
val navigationContentShiftDp: Int,
)
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
data class NavigationRemoteConfig(
val labels: NavigationLabels,
val tagline: String,
val showVersion: Boolean,
val expandedWidthDp: Int,
val contentShiftDp: Int,
)
object BundledRemoteConfig {
val value = MembyRemoteConfig(
schemaVersion = REMOTE_CONFIG_SCHEMA_VERSION,
// Bundled data is the floor, not a remotely issued revision. Any valid server
// document therefore wins on a later process start.
configVersion = 0,
copy = RemoteCopy(
tagline = "Matts Android TV client",
navigation = NavigationLabels(
home = "Home",
forYou = "For You",
search = "Search",
movies = "Movies",
tvShows = "TV Shows",
tvCalendar = "TV Calendar",
favourites = "Favourites",
user = "User",
settings = "Settings",
),
),
features = RemoteFeatures(showNavigationVersion = true),
presentation = RemotePresentation(
navigationRailExpandedWidthDp = 184,
navigationContentShiftDp = 112,
),
)
}
@Serializable
private data class CachedRemoteConfig(
val etag: String,
val document: MembyRemoteConfig,
)
private val remoteConfigJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
/**
* Owns synchronous activation and asynchronous revalidation.
*
* SharedPreferences gives this one small value an atomic file replacement. The network
* path commits only after decoding and validation, on an IO dispatcher, and never mutates
* [active]. A killed write therefore leaves either the old complete value or the new one.
*/
class RemoteConfigManager(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
private val cachedAtStart = preferences.getString(DOCUMENT_KEY, null)
?.let(::decodeCachedRemoteConfig)
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
val active: MembyRemoteConfig = cachedAtStart?.document ?: BundledRemoteConfig.value
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Schedules a tiny revalidation after the launcher's critical start-up work. */
fun refreshLater() {
val gatewayUrl = ServerConfig.gatewayUrl ?: return
val cached = cachedAtStart
scope.launch {
delay(REMOTE_CONFIG_START_DELAY_MS)
fetch(gatewayUrl, cached)
}
}
private fun fetch(gatewayUrl: String, cached: CachedRemoteConfig?) {
val request = Request.Builder()
.url(gatewayUrl.trimEnd('/') + "/v1/config")
.header("Accept", "application/json")
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
.header("X-Memby-Config-Schema", REMOTE_CONFIG_SCHEMA_VERSION.toString())
.apply { cached?.etag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
.build()
val client = HttpStack.base.newBuilder()
// This request is speculative and affects only a later process. Give it less
// patience than any screen-facing call and never retry it in this session.
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.callTimeout(3, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
runCatching {
client.newCall(request).execute().use { response ->
if (response.code == 304) return
if (!response.isSuccessful) return
val body = response.body ?: return
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return
val source = body.source()
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return
val raw = source.readUtf8()
val document = decodeRemoteConfig(raw) ?: return
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return
val heldVersion = cached?.document?.configVersion ?: 0
if (document.configVersion <= heldVersion) return
val etag = response.header("ETag")?.takeIf {
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
} ?: return
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
}
}
}.onFailure {
// A failed speculative refresh is ordinary offline behaviour. The active
// bundled/cached document remains complete and no screen needs to know.
Log.d("MembyRemoteConfig", "Remote configuration refresh skipped", it)
}
}
}
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
}.getOrNull()
private fun decodeCachedRemoteConfig(raw: String): CachedRemoteConfig? = runCatching {
remoteConfigJson.decodeFromString<CachedRemoteConfig>(raw)
}.getOrNull()
/** Returns null only when the whole document is safe to activate. */
internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: String): String? {
if (document.schemaVersion != REMOTE_CONFIG_SCHEMA_VERSION) return "unsupported schema"
if (document.configVersion < 1) return "invalid configuration version"
val current = semanticVersion(appVersion) ?: return "invalid app version"
document.minimumAppVersion.takeIf(String::isNotBlank)?.let { minimum ->
val parsed = semanticVersion(minimum) ?: return "invalid minimum app version"
if (current < parsed) return "app is older than the configuration minimum"
}
document.maximumAppVersion.takeIf(String::isNotBlank)?.let { maximum ->
val parsed = semanticVersion(maximum) ?: return "invalid maximum app version"
if (current > parsed) return "app is newer than the configuration maximum"
}
val copy = document.copy
val labels = listOf(
copy.tagline,
copy.navigation.home,
copy.navigation.forYou,
copy.navigation.search,
copy.navigation.movies,
copy.navigation.tvShows,
copy.navigation.tvCalendar,
copy.navigation.favourites,
copy.navigation.user,
copy.navigation.settings,
)
if (labels.any { it.isBlank() || it != it.trim() || it.length > 64 || it.any(Char::isISOControl) }) {
return "invalid copy"
}
val width = document.presentation.navigationRailExpandedWidthDp
val shift = document.presentation.navigationContentShiftDp
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
return "unsafe presentation values"
}
return null
}
private data class SemanticVersion(val major: Int, val minor: Int, val patch: Int) :
Comparable<SemanticVersion> {
override fun compareTo(other: SemanticVersion): Int =
compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch)
}
private fun semanticVersion(raw: String): SemanticVersion? {
val parts = raw.split('.')
if (parts.size != 3) return null
val numbers = parts.map { it.toIntOrNull() ?: return null }
if (numbers.any { it < 0 }) return null
return SemanticVersion(numbers[0], numbers[1], numbers[2])
}
@@ -140,6 +140,9 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -236,6 +239,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
@Composable
fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
selected: BrowseDestination,
focusDestination: BrowseDestination = selected,
expanded: Boolean,
@@ -269,7 +273,7 @@ fun TvNavigationRail(
}
}
val railWidth = animateDpAsState(
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
animationSpec = tween(160),
label = "navigation-rail-width",
)
@@ -339,7 +343,7 @@ fun TvNavigationRail(
maxLines = 1,
)
Text(
"Matts Android TV client",
config.tagline,
color = QuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
@@ -364,7 +368,7 @@ fun TvNavigationRail(
label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername)
} else {
destination.label
configuredNavigationLabel(destination, config.labels)
},
selected = destination == selected,
expanded = expanded,
@@ -389,18 +393,35 @@ fun TvNavigationRail(
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.weight(1f))
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
if (config.showVersion) {
Text(
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
color = QuietText,
fontSize = if (expanded) 10.sp else 8.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
)
}
}
}
}
private fun configuredNavigationLabel(
destination: BrowseDestination,
labels: NavigationLabels,
): String = when (destination) {
BrowseDestination.HOME -> labels.home
BrowseDestination.FOR_YOU -> labels.forYou
BrowseDestination.SEARCH -> labels.search
BrowseDestination.MOVIES -> labels.movies
BrowseDestination.SHOWS -> labels.tvShows
BrowseDestination.CALENDAR -> labels.tvCalendar
BrowseDestination.FAVORITES -> labels.favourites
BrowseDestination.PROFILES -> labels.user
BrowseDestination.SETTINGS -> labels.settings
}
@Composable
fun UserSwitcherOverlay(
profiles: List<EmbyProfile>,
@@ -389,14 +389,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Resolution is tiny compared with video buffering and makes the later
// Play click a memory lookup. The repository single-flights requests,
// so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
// The row owns live UserData. Prefetching the metadata-cache copy used
// to turn a 5% resume point into zero after details had been visited.
launch { runCatching { repository.prefetchPlayable(focused) } }
}
// Do not negotiate playback on focus. Emby creates a playback session as
// part of PlaybackInfo, so warming a stream here made merely browsing a
// shelf appear in server history as something the viewer had played.
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
@@ -423,7 +418,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens — the item
* record, its "why you might enjoy it" and its playable URL are all warmed above — but
* record and its "why you might enjoy it" are warmed above — but
* the episode list and the trailer were not, so a series page opened with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
@@ -131,6 +131,7 @@ import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
@@ -246,8 +247,11 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document
// is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active
setContent {
MembyTheme { AppRoot(onCloseSettings = ::finish) }
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
}
PerformanceMonitor.start(this)
}
@@ -298,7 +302,7 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable
private fun AppRoot(onCloseSettings: () -> Unit) {
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
@@ -643,7 +647,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
}
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded)
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
}
// Snow, bats or blossom for the few days a year a season is on, over the
// launcher and nowhere else. Not over playback — a film is the one thing
@@ -1887,6 +1891,7 @@ private fun ProfileTile(
@Composable
private fun HomeScreen(
settings: Settings,
remoteConfig: MembyRemoteConfig,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
@@ -2347,13 +2352,14 @@ private fun HomeScreen(
}
val contentShift by animateDpAsState(
targetValue = if (navigationExpanded) TvRailContentShift else 0.dp,
targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp,
animationSpec = tween(150),
label = "navigation-content-shift",
)
Box(Modifier.fillMaxSize().background(MembySurface)) {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
config = remoteConfig.navigation,
selected = selectedDestination,
focusDestination = railFocusDestination,
expanded = navigationExpanded,
@@ -316,7 +316,7 @@ internal fun MyShowDetailsOverlay(
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
StatusLine("Sonarr", show.sonarrStatus)
StatusLine("Series", show.sonarrStatus)
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
StatusLine("Series status", show.lifecycle)
Row(
@@ -156,7 +156,7 @@ internal fun CalendarContent(
state.errorMessage.orEmpty(), "Try again", onRetry,
)
!state.calendar.available -> AgendaNotice(
"The TV calendar is not available. It needs the Memby gateway with Sonarr configured.",
"The TV calendar is not available. It needs the Memby gateway with a series service configured.",
)
week == null -> AgendaNotice("Nothing scheduled.")
else -> {
@@ -1029,7 +1029,7 @@ private fun RequestOptions(
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
"Search for movies and series, then choose the exact title you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
@@ -0,0 +1,91 @@
package com.ponzischeme89.memby.data.remoteconfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class MembyRemoteConfigTest {
@Test
fun bundledDefaultsAreCompleteButNotARemoteRevision() {
val bundled = BundledRemoteConfig.value
assertEquals(0, bundled.configVersion)
assertEquals("Favourites", bundled.navigation.labels.favourites)
assertEquals(184, bundled.navigation.expandedWidthDp)
}
@Test
fun validCompatibleDocumentDecodes() {
val document = decodeRemoteConfig(validDocument)!!
assertNull(validateRemoteConfig(document, "0.2.54"))
assertEquals("Discover", document.navigation.labels.search)
assertEquals(false, document.navigation.showVersion)
}
@Test
fun incompatibleSchemaAndAppVersionsAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
assertNotNull(validateRemoteConfig(document.copy(schemaVersion = 2), "0.2.54"))
assertNotNull(validateRemoteConfig(document, "0.2.53"))
assertNotNull(
validateRemoteConfig(
document.copy(minimumAppVersion = "", maximumAppVersion = "0.2.53"),
"0.2.54",
),
)
}
@Test
fun malformedCopyAndUnsafePresentationAreRejected() {
val document = decodeRemoteConfig(validDocument)!!
assertNotNull(
validateRemoteConfig(
document.copy(copy = document.copy.copy(tagline = "\nNot safe")),
"0.2.54",
),
)
assertNotNull(
validateRemoteConfig(
document.copy(
presentation = document.presentation.copy(
navigationRailExpandedWidthDp = 500,
),
),
"0.2.54",
),
)
}
private companion object {
val validDocument = """
{
"schemaVersion": 1,
"configVersion": 7,
"minimumAppVersion": "0.2.54",
"copy": {
"navigation": {
"home": "Home",
"forYou": "For You",
"search": "Discover",
"movies": "Films",
"tvShows": "TV Shows",
"tvCalendar": "TV Calendar",
"favourites": "Favourites",
"user": "User",
"settings": "Settings"
},
"tagline": "Your library, made personal"
},
"features": { "showNavigationVersion": false },
"presentation": {
"navigationRailExpandedWidthDp": 196,
"navigationContentShiftDp": 120
}
}
""".trimIndent()
}
}