Keep shell state reads safe in read-only snapshots
All checks were successful
review/code-claude Hotfix verified on the real Xiaomi box (B3 runs, no crash across Home/Discover/Movies/Shows/title/Settings/Requests); 5-file delta read by firstmate: ShellState.rememberedView no longer writes on read, 3 regression tests incl. snapshotFlow and an audit

This commit is contained in:
justinzeus 2026-11-06 10:50:56 +01:00
parent e9a92eba49
commit 4e6377b628
5 changed files with 199 additions and 3 deletions

View file

@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** No drawn screen may prewarm memory before the production Discover observer starts. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class DiscoverSnapshotReadTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val identity = requireNotNull(session.identity.value)
assertTrue(identity.signedIn)
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared")
lateinit var current: DiscoverState
rule.setContent {
current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false,
loadFixture = { error("Production observer must not load fixtures") })
}
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
current.snapshot.status == DiscoverStatus.Ready
}
rule.waitForIdle()
rule.runOnIdle {
assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") })
assertEquals(0, current.focusedRow)
assertEquals(0, current.column)
current.focus(current.firstShelf)
}
rule.waitForIdle()
rule.runOnIdle {
assertNotNull(current.focusedLane)
assertEquals(current.firstShelf, current.focusedRow)
assertEquals(0, current.column)
}
}
}
}
}

View file

@ -78,6 +78,13 @@ resolves consistently in native captures; the approved JSON weights stay unchang
Nav, picker names, labels and pill weights use that same mapping. Nav label colour Nav, picker names, labels and pill weights use that same mapping. Nav label colour
fades for 200 ms ease-out; the blob retains its critically damped spring. fades for 200 ms ease-out; the blob retains its critically damped spring.
Shell view accessors are read-only, including the first read from Discover's
`snapshotFlow`. Construction and explicit person/navigation events retain root
and destination memory; reading an unknown destination returns an unsaved default.
Per-person focus, cards and scrollers still survive route changes, while reselecting
the current root resets it. JVM regressions exercise both read-only snapshots and
the signed-in asynchronous Discover host without a screen prewarming its memory.
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width

View file

@ -57,6 +57,7 @@ class ShellState(
people = value people = value
if (selected != null) person = selected if (selected != null) person = selected
pickerIndex = people.indexOf(person).coerceAtLeast(0) pickerIndex = people.indexOf(person).coerceAtLeast(0)
preparePersonMemory()
} }
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set private set
@ -73,7 +74,7 @@ class ShellState(
var requestsVisible by mutableStateOf(false) var requestsVisible by mutableStateOf(false)
var toast by mutableStateOf<String?>(null) var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>() private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() } val requests: ComingInState get() = personRequests.getValue(person.key)
private var requestReturnRoute: String? = null private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null) var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0) var settingsRow by mutableStateOf(0)
@ -85,17 +86,30 @@ class ShellState(
var contentFocus: ShellContentFocus? = null var contentFocus: ShellContentFocus? = null
var inputRevision = 0L var inputRevision = 0L
private set private set
init { preparePersonMemory() }
private fun preparePersonMemory() {
personRequests.getOrPut(person.key) { ComingInState() }
tabs.forEach(::retainView)
retainView(route)
}
private fun retainView(destination: String) {
views.getOrPut(person.key to destination) { RememberedView() }
}
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = rememberedView(route) val view: RememberedView get() = rememberedView(route)
val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt
/** Screen options share the same per-person memory as their root's focus and scroller. */ /** Reads never register defaults: snapshotFlow evaluates accessors in a read-only snapshot. */
fun rememberedView(destination: String): RememberedView = fun rememberedView(destination: String): RememberedView =
views.getOrPut(person.key to destination) { RememberedView() } views[person.key to destination] ?: RememberedView()
/** External intents and upstream pops remain authoritative over the screen. */ /** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) { fun enterDestination(destination: String) {
retainView(destination)
route = destination route = destination
if (destination in tabs) rootRoute = destination if (destination in tabs) rootRoute = destination
handOff = null handOff = null
@ -159,6 +173,7 @@ class ShellState(
pickerVisible = false pickerVisible = false
navFocused = false navFocused = false
settingsRow = 0 settingsRow = 0
retainView("settings")
route = "settings" route = "settings"
onAction(ShellAction.Destination(route)) onAction(ShellAction.Destination(route))
} }
@ -187,6 +202,7 @@ class ShellState(
rootRoute = "home" rootRoute = "home"
requestReturnRoute = null requestReturnRoute = null
requestsVisible = false requestsVisible = false
preparePersonMemory()
views[person.key to route] = RememberedView() views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected)) onAction(ShellAction.Person(selected))
} }
@ -226,6 +242,7 @@ class ShellState(
} else { } else {
val target = tabs[navIndex] val target = tabs[navIndex]
requestReturnRoute = null requestReturnRoute = null
retainView(target)
if (target == route) views[person.key to target] = RememberedView() if (target == route) views[person.key to target] = RememberedView()
route = target route = target
rootRoute = target rootRoute = target

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createComposeRule
import kotlinx.coroutines.flow.first
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A first read must work without composition prewarming the person's route memory. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ShellSnapshotReadTest {
@get:Rule val rule = createComposeRule()
@Test fun unseenViewCanBeReadInAReadOnlySnapshot() {
val shell = ShellState(fixturePeople(), "justin")
val snapshot = Snapshot.takeSnapshot()
try {
snapshot.enter {
assertEquals(0, shell.view.row)
assertTrue(shell.view.cards.isEmpty())
assertNull(shell.view.savedAt)
assertEquals(0, shell.rememberedView("never-seen").row)
assertNotSame(shell.rememberedView("never-seen"), shell.rememberedView("never-seen"))
}
} finally { snapshot.dispose() }
}
@Test fun launchedSnapshotFlowReadsDefaultsAndThenRetainsExplicitNavigationMemory() {
val shell = ShellState(fixturePeople(), "justin")
var observed: RememberedView? = null
rule.setContent {
LaunchedEffect(shell) { observed = snapshotFlow { shell.view }.first() }
}
rule.runOnIdle {
val home = requireNotNull(observed)
assertEquals(0, home.row)
assertTrue(home.cards.isEmpty())
home.row = 3
home.cards["shelf"] = 2
shell.enterDestination("movies")
assertEquals(0, shell.view.row)
shell.view.row = 5
shell.enterDestination("home")
assertSame(home, shell.view)
assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards["shelf"])
shell.enterDestination("movies")
assertEquals(5, shell.view.row)
shell.focusPerson(1)
shell.key(ShellKey.Ok)
shell.enterDestination("movies")
assertEquals(0, shell.view.row)
shell.view.row = 7
shell.pickerVisible = true
shell.focusPerson(0)
shell.key(ShellKey.Ok)
shell.enterDestination("movies")
assertEquals(5, shell.view.row)
}
}
}

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.io.File
import org.junit.Assert.*
import org.junit.Test
/** Guard known state writers; runtime tests cover transitive property reads. */
class SnapshotReadAuditTest {
@Test fun snapshotObserversDoNotCallKnownStateWriters() {
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
val violations = roots.flatMap { root ->
assertTrue("Missing owned source root: $root", root.isDirectory)
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { file ->
observerBodies(file.readText()).filter { writingCalls.containsMatchIn(it) }.map { "$file: $it" }
}.toList()
}
assertTrue(violations.joinToString("\n"), violations.isEmpty())
}
@Test fun shellReadAccessorDoesNotInsertMemory() {
val source = File("src/main/java/org/justinvisser/wolfskin/design/ShellState.kt").readText()
val accessor = source.substringAfter("fun rememberedView(").substringBefore("/**")
assertFalse("rememberedView must not insert into snapshot state", Regex("getOrPut|\\.put\\s*\\(|\\]\\s*=").containsMatchIn(accessor))
}
@Test fun knownWriterIsRejectedWithoutRejectingItsCollector() {
val unsafe = observerBodies("snapshotFlow { state.focus(2) }").single()
assertTrue(writingCalls.containsMatchIn(unsafe))
val safe = observerBodies("snapshotFlow { state.focusedLane?.id to state.column }.collect { state.focus(2) }").single()
assertFalse(writingCalls.containsMatchIn(safe))
}
private fun observerBodies(source: String): List<String> = Regex("snapshotFlow\\s*\\{").findAll(source).map { match ->
val start = match.range.last + 1
var depth = 1
var end = start
while (end < source.length && depth > 0) {
when (source[end]) { '{' -> depth++; '}' -> depth-- }
end++
}
source.substring(start, end - 1)
}.toList()
private val writingCalls = Regex("\\b(?:retainView|preparePersonMemory|enterDestination|focus|updateSnapshot|updateLanes|invalidateLanes|updateRatings|recordCenter|getOrPut|put|putAll|clear|remove)\\s*\\(")
}