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

@ -57,6 +57,7 @@ class ShellState(
people = value
if (selected != null) person = selected
pickerIndex = people.indexOf(person).coerceAtLeast(0)
preparePersonMemory()
}
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set
@ -73,7 +74,7 @@ class ShellState(
var requestsVisible by mutableStateOf(false)
var toast by mutableStateOf<String?>(null)
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
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
@ -85,17 +86,30 @@ class ShellState(
var contentFocus: ShellContentFocus? = null
var inputRevision = 0L
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 currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
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
/** 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 =
views.getOrPut(person.key to destination) { RememberedView() }
views[person.key to destination] ?: RememberedView()
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {
retainView(destination)
route = destination
if (destination in tabs) rootRoute = destination
handOff = null
@ -159,6 +173,7 @@ class ShellState(
pickerVisible = false
navFocused = false
settingsRow = 0
retainView("settings")
route = "settings"
onAction(ShellAction.Destination(route))
}
@ -187,6 +202,7 @@ class ShellState(
rootRoute = "home"
requestReturnRoute = null
requestsVisible = false
preparePersonMemory()
views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected))
}
@ -226,6 +242,7 @@ class ShellState(
} else {
val target = tabs[navIndex]
requestReturnRoute = null
retainView(target)
if (target == route) views[person.key to target] = RememberedView()
route = target
rootRoute = target