From 1ae07b93eca98022ebdacef12f33e1229e8336b4 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 24 Aug 2026 09:58:09 +1200 Subject: [PATCH] 0.3.17 Fix the crash on every cold start of a signed-in television. MembyIcons' currentIconPack and DesignTokens' activePalette are process-wide mutableStateOf. The first thing in the process to touch either was ThemeSync, applying the cached pack and palette from an IO thread within milliseconds of the settings store's first emission -- reliably after MainActivity had taken its composition snapshot. A state object created in a newer snapshot than the composition reading it makes that read throw, so HomeNavigation's first MembyIcon.mark read died with "Reading a state that was created after the snapshot was taken". MembyApp.onCreate now creates both on the main thread before anything else can. Both calls are value-level no-ops; what matters is the thread and the ordering. It was invisible on a fresh install because first-run and sign-in draw no marks, which is why clearing app data appeared to fix it -- and it looked like an update bug only because replacing the APK is what forces a cold start with the launcher as the first screen. A reboot did it too. Also: AppRoot filtered neither read of the persisted requiredUpdateVersion through requiredUpdateSatisfied, so a set that had just installed the demanded version opened on the retired-build screen it was updating to escape, until a /v1/update round trip completed. Both reads now apply the rule the in-memory signal and markRequiredUpdate already did. Verified on 10.0.0.238: three cold starts, no crash, launcher fully drawn. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HoEWidWhZu9kZzNURatmcT --- CHANGELOG.md | 4 ++++ app/build.gradle.kts | 2 +- .../java/com/ponzischeme89/memby/MembyApp.kt | 17 +++++++++++++++++ .../java/com/ponzischeme89/memby/ui/AppRoot.kt | 12 ++++++++++-- .../memby/ui/theme/DesignTokens.kt | 7 +++++++ .../ponzischeme89/memby/ui/theme/MembyIcons.kt | 13 +++++++++++++ 6 files changed, 52 insertions(+), 3 deletions(-) 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) /**