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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoEWidWhZu9kZzNURatmcT
This commit is contained in:
co-authored by
Claude Opus 5
parent
067395a362
commit
1ae07b93ec
@@ -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()
|
||||
|
||||
@@ -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' <clinit>, 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user