Add TV offline recovery and per-person state isolation
All checks were successful
review/design-claude Claude Sonnet cross-family review: pass, no blockers

This commit is contained in:
justinzeus 2026-11-05 22:11:00 +01:00
parent 9dd321f07e
commit a01c0a78fb
29 changed files with 783 additions and 86 deletions

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved Home projections survive outages but never survive an authenticated identity change. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class HomeRecoveryTest {
private val context = ApplicationProvider.getApplicationContext<Application>()
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
private val library = HomeLibrary(resume = listOf(film))
@Test fun savedHomePaintsBeforeRefreshAndStaysAfterFailedRetry() = runBlocking {
val cache = HomeLibraryCache(context)
cache.write("${server.id}:${personA.id}", library, 1000L)
val started = CompletableDeferred<Unit>()
val response = CompletableDeferred<HomeLibrary>()
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
response.completeExceptionally(IOException("fixture offline"))
refresh.await()
assertFalse(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertNotNull(model.snapshot.savedAt)
assertEquals(RecoveryMessage.StillUnreachable, model.toast)
}
@Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking {
val facade = mockk<HomeReadFacade>()
val cache = HomeLibraryCache(context)
cache.write("${server.id}:${personA.id}", library, 1000L)
val startedA = CompletableDeferred<Unit>()
val responseA = CompletableDeferred<HomeLibrary>()
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
model.loadLibrary(server, personB, "mindel")
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
assertNull(model.snapshot.savedAt)
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
responseA.complete(library)
requestA.await()
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
assertFalse(model.snapshot.jellyfinAvailable)
}
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
model.loadLibrary(server, personA, "justin")
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertNull(model.toast)
}
}

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.io.File
import java.io.IOException
import java.util.UUID
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Actual Home actions either open upstream playback or leave focus and the saved Home intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PlayerHandoffTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
private val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Mindel", serverId = server.id, accessToken = null)
private val navigation = NavigationManager()
private val facade = mockk<HomeReadFacade>()
private lateinit var model: WolfskinHomeViewModel
private lateinit var shell: ShellState
@Before fun setup() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists())
}
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
val title = snapshot.rows.first().cards.first()
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
model.toast?.let { RecoveryToast(it) }
}
} }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
awaitModel { model.toast != null }
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
assertFalse(shell.navFocused)
assertEquals("hero", snapshot.rows[shell.view.row].id)
assertEquals(0, shell.view.cards["hero"] ?: 0)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath)
}
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.playableId(user.id, title) } returns id
val view = shell.view
view.cards["hero"] = 0
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback }
val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId)
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
assertTrue(destination.fullScreen)
navigation.goBack()
assertTrue(navigation.backStack.single() is Destination.Home)
assertSame(view, shell.view)
assertEquals(0, shell.view.cards["hero"])
assertNull(model.toast)
}
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val otherUser = user.copy(name = "Justin")
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
awaitModel { model.toast != null }
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
assertTrue(navigation.backStack.single() is Destination.Home)
coVerify(exactly = 0) { facade.playableId(any(), any()) }
}
private fun awaitModel(condition: () -> Boolean) {
rule.waitUntil(5000) {
shadowOf(Looper.getMainLooper()).idle()
condition()
}
}
}

View file

@ -1,15 +1,12 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
@ -42,27 +39,43 @@ class WolfskinHomeViewModel @Inject constructor(
private set
var toast by mutableStateOf<String?>(null)
private set
private var sourceScope: String? = null
private var loadRevision = 0L
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
loadLibrary(server, user, person, retry)
}
/** The authenticated read path is shared by release and offline fixture-driven tests. */
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
val revision = ++loadRevision
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (person != user.name.orEmpty().lowercase()) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
return
}
val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
if (sourceScope != scope) {
sourceScope = scope
toast = null
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
}
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
if (revision != loadRevision) return
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
try {
val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } }
if (revision != loadRevision) return
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
if (revision != loadRevision) return
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
if (retry) showToast(RecoveryMessage.StillUnreachable)
}
}
@ -74,16 +87,16 @@ class WolfskinHomeViewModel @Inject constructor(
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
is HomeAction.Play -> viewModelScope.launch {
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
showToast(RecoveryMessage.PlaybackUnavailable)
return@launch
}
try {
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
showToast(RecoveryMessage.PlaybackUnavailable)
}
}
}
@ -126,8 +139,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
Box(modifier) {
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message ->
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}
model.toast?.let { RecoveryToast(it) }
}
}

View file

@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens
@Composable
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
val owner = LocalLifecycleOwner.current
LaunchedEffect(state, owner) {
LaunchedEffect(state, state.person.key, owner) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
refreshRequestsWhileStarted(state, refresh)
}

View file

@ -102,10 +102,17 @@ class WolfskinShellViewModel @Inject constructor(
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
fun refreshRequests() {
fun refreshRequests() = refreshRequests(retry = false)
private fun refreshRequests(retry: Boolean) {
val shell = session.state ?: return
val target = shell.requests
viewModelScope.launch {
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
session.state?.requests?.update(snapshot)
target.update(snapshot)
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
}
}
}
@ -116,7 +123,7 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
ShellAction.RetryRequests -> refreshRequests()
ShellAction.RetryRequests -> refreshRequests(retry = true)
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
}
}
@ -206,7 +213,7 @@ fun WolfskinApp(
ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->

View file

@ -84,6 +84,8 @@ fun WolfskinLibraryContent(
grid.source = snapshot.items
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
}
}
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false