diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fb783..f2d0d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.17 - 2026-08-24 +- Fixed: Crash on every cold start of a signed-in television. `MembyIcons`' icon-pack state and `DesignTokens`' palette state are process-wide `mutableStateOf`, and the first thing to touch either was `ThemeSync` applying the cached pack from an IO thread while the launcher was composing — so the state object was created in a snapshot newer than the composition reading it, and the navigation rail's first `MembyIcon.mark` read threw "Reading a state that was created after the snapshot was taken". `MembyApp.onCreate` now creates both on the main thread before anything else in the process can. Invisible on a fresh install because first-run and sign-in draw no marks, which is why clearing app data appeared to fix it. +- Fixed: A television that had just installed the version the gateway demanded opened on the retired-build screen it was updating to escape. `AppRoot` now filters the persisted `requiredUpdateVersion` through `requiredUpdateSatisfied`, the same rule the in-memory signal and `markRequiredUpdate` already apply. + ## 0.2.98 - 2026-08-22 - v298 closes the following issues: 116 (Feature: Integrate Memby with Android TV Home Screen), 117 (Improve Row Navigation & Add “View All” Actions) and 115 (Add ratings strip to movie continue watching). - Added: Continue Watching support on the Google TV launcher homescreen (116) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac087bf..6afd8d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" -val defaultVersionName = "0.3.16" +val defaultVersionName = "0.3.17" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt index f60e947..70b72d1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt +++ b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt @@ -9,6 +9,10 @@ import com.ponzischeme89.memby.data.remote.HttpStack import com.ponzischeme89.memby.performance.StartupTrace import com.ponzischeme89.memby.ui.TitleLogoCache import com.ponzischeme89.memby.ui.player.PrerollPreloader +import com.ponzischeme89.memby.ui.theme.MembyPalette +import com.ponzischeme89.memby.ui.theme.applyMembyIconPack +import com.ponzischeme89.memby.ui.theme.applyMembyPalette +import com.ponzischeme89.memby.ui.theme.membyIconPackFor import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit @@ -19,6 +23,19 @@ class MembyApp : Application() { // is measured from as close to the beginning as this code can stand. Debug-only and // a single field write; on a release build it returns before allocating. StartupTrace.appStart() + // Create the two process-wide token holders here, on the main thread, before + // anything else in the process can touch them. Both are `mutableStateOf` and both + // are read by every screen; the first thing that would otherwise reach them is + // ThemeSync, which applies the cached pack and palette from an IO thread within + // milliseconds of the settings store's first emission. When that IO write is what + // runs these files' , the state object is created in a snapshot newer than + // the launcher's composition, and HomeNavigation's first read of MembyIcon.mark + // throws "Reading a state that was created after the snapshot was taken" — a crash + // on every cold start of a signed-in television, invisible to a fresh install + // because sign-in and first-run draw no marks. Both calls are value-level no-ops: + // what matters is where and on which thread the class initialiser runs. + applyMembyPalette(MembyPalette()) + applyMembyIconPack(membyIconPackFor(null)) Coil.setImageLoader( ImageLoader.Builder(this) // Coil builds its own OkHttpClient when not given one, which would mean a diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt index 349ebf8..fbc877b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt @@ -137,7 +137,8 @@ internal fun AppRoot( // What the gateway has already said about this build, read from disk rather // than from this loop's memory — the refusal is commonly one process old. val retired = ServiceLocator.settings.current - ?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + ?.requiredUpdateVersion + ?.takeIf { it.isNotBlank() && !requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it) } ?: reportedRequiredUpdate val result = if (firstCheck && retired == null) { // A disconnected server must not strand an otherwise usable TV at boot. @@ -268,7 +269,14 @@ internal fun AppRoot( // verdict that no longer requires one — this is the only screen the television may // show: its session is gone, and sign-in, profiles and the launcher are all things // the server will refuse. - val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + // Filtered through the same rule the in-memory signal and [markRequiredUpdate] both + // apply: a refusal this build already satisfies describes an APK that is no longer + // here. Without it a television that has just installed the demanded version opens + // on the retired-build screen it was updating to escape, and stays there until a + // /v1/update round trip completes — which on an unreachable gateway is indefinite, + // with clearing app data the only way out. + val retiredVersion = loaded?.requiredUpdateVersion + ?.takeIf { it.isNotBlank() && !requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it) } ?: reportedRequiredUpdate // One call site survives every state that still means "opening", so the local // poster snapshot never reloads while update/session/onboarding checks settle. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt index 7d4d77d..bbb05b9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt @@ -60,6 +60,13 @@ data class MembyPalette( * Being snapshot state is what makes the tokens below work without a `@Composable` * annotation: a read inside composition or a draw scope is recorded, so assigning here * repaints exactly the scopes that use the colour that changed. + * + * **It must be created on the main thread, before the first composition**, for the reason + * `MembyIcons`' `currentIconPack` must be: process-wide snapshot state created in a newer + * snapshot than the composition that reads it throws "Reading a state that was created + * after the snapshot was taken", and the first thing to touch this file would otherwise be + * `ThemeSync` applying the cached palette from an IO thread mid-composition. [MembyApp] + * initialises it deliberately. */ private var activePalette by mutableStateOf(MembyPalette()) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt index 22d2bba..35f7641 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt @@ -142,6 +142,19 @@ class MembyIconPack( marks[slot]?.invoke() ?: MaterialIconPack.fallback(slot) } +/** + * The pack every mark in the app is drawn from. + * + * **This must be created on the main thread, before the first composition.** It is + * process-wide snapshot state, so the snapshot it is created in has to be older than any + * composition that reads it — and the first thing to touch this file is otherwise + * `ThemeSync`, applying the cached pack from an IO thread while the launcher is composing. + * A state object created in a newer snapshot than the composition reading it throws + * "Reading a state that was created after the snapshot was taken", which is a crash on + * every cold start of a signed-in television. [MembyApp] initialises it deliberately; do + * not make that call conditional, and do not add another lazily initialised holder beside + * it without doing the same. + */ private val currentIconPack = mutableStateOf(MaterialIconPack.pack) /**