From d401ebc35541eace57dc3feb9f647c8e91adee7a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 11:27:35 +0100 Subject: [PATCH 1/7] Fix TV startup navigation and complete title pages --- .../wolfskin/tv/DiscoverLoadingFocusTest.kt | 1 - .../wolfskin/tv/DiscoverTitleTimingTest.kt | 98 +++++++++++ .../wolfskin/tv/FakeConductarr.kt | 2 + .../wolfskin/tv/NavBandOcclusionTest.kt | 54 ++++++ .../wolfskin/tv/ShellStartupTimingTest.kt | 163 ++++++++++++++++++ .../wolfskin/tv/TitleReadFacadeTest.kt | 24 +++ .../justinvisser/wolfskin/tv/DiscoverHost.kt | 12 -- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 8 +- .../wolfskin/tv/read/TitleReadFacade.kt | 18 +- docker/README.md | 16 ++ .../wolfskin/design/DiscoverState.kt | 4 +- .../justinvisser/wolfskin/design/NavBar.kt | 7 +- .../wolfskin/design/DiscoverKeysTest.kt | 2 +- 13 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt index b18f6a22..d2530d4f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest { rule.runOnIdle { assertNotNull(current.title) assertEquals("The Amazing Spider-Man", current.title!!.title) - assertEquals("discover-title/${current.title!!.key}", shell.route) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt new file mode 100644 index 00000000..9fbbeaa0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -0,0 +1,98 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import io.mockk.coEvery +import io.mockk.mockk +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.cancel +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.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverTitleTimingTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { + FakeConductarr().use { fake -> + fake.localArt = true + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val identity = session.identity.value!! + val facade = mockk() + coEvery { facade.findHeld(any(), any()) } returns null + val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + lateinit var discover: DiscoverState + val release = CountDownLatch(1) + try { + rule.setContent { + val route = (navigation.backStack.last() as Destination.Wolfskin).route + LaunchedEffect(route) { shell.enterDestination(route) } + discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false) + WolfskinTheme { + if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) + else ShellSurface(shell) { DiscoverScreen(discover, it) } + } + } + rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready } + rule.runOnIdle { + fake.beforeResponse = { if (it.endsWith("/cast")) release.await() } + discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning")) + } + rule.waitForIdle() + assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/")) + rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } } + rule.runOnIdle { release.countDown() } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true + } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("cast", model.title!!.row) + rule.onNodeWithText("Sam Rockwell").assertIsDisplayed() + val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("related", model.title!!.row) + rule.onNodeWithText("More like this").assertIsDisplayed() + rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.Back) } + rule.waitForIdle() + assertNull(discover.title) + assertEquals("discover", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index b682b34c..c751b56e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -26,6 +26,7 @@ internal class FakeConductarr : Closeable { @Volatile var emptyAudiences = false @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false + @Volatile var beforeResponse: ((String) -> Unit)? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -37,6 +38,7 @@ internal class FakeConductarr : Closeable { if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder() .body(okio.Buffer().write(imageBytes)).build() val path = request.url.encodedPath + beforeResponse?.invoke(path) if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) if (request.method == "POST") return response(wire.getValue("hide").toString()) if (request.method == "DELETE") return response(wire.getValue("restore").toString()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt new file mode 100644 index 00000000..5f2aa9e7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt @@ -0,0 +1,54 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Scrolled cards must be occluded across the entire persistent navigation band. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavBandOcclusionTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun scrolledRowsNeverShowThroughTheNavBand() { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) { + items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) } + } + } } } + rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } } + rule.waitForIdle() + val pixels = rule.onRoot().captureToImage().toPixelMap() + val background = WolfskinTokens.Color.bg + for (y in listOf(5, 50, 135)) { + val actual = pixels[20, y] + assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f) + assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f) + assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt new file mode 100644 index 00000000..c3bf121a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -0,0 +1,163 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +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 io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlinx.coroutines.* +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellStartupTimingTest { + @Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun resetMain() { Dispatchers.resetMain() } + + @Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + conductarr.authenticate() + val activityEntered = CompletableDeferred() + val releaseActivity = java.util.concurrent.CountDownLatch(1) + fake.beforeResponse = { + if (it == "/api/v1/activity") { + activityEntered.complete(Unit) + releaseActivity.await() + } + } + val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() } + withTimeout(5_000) { activityEntered.await() } + val savedPeople = CompletableDeferred() + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null } + val navigation = mockk(relaxed = true) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val load = launch { model.load(server, user) } + try { + yield() + val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" } + assertEquals(PeopleStatus.Ready, shell.peopleStatus) + assertEquals(user.id.toString(), shell.person.jellyfinUserId) + shell.key(ShellKey.Ok) + for (route in listOf("movies", "shows", "discover")) { + shell.focusNav(shell.tabs.indexOf(route)) + shell.key(ShellKey.Ok) + verify { navigation.replace(Destination.Wolfskin(route)) } + assertEquals(route, shell.route) + } + savedPeople.complete(Unit) + load.join() + assertSame(shell, session.state) + assertEquals("discover", shell.route) + assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted) + } finally { + releaseActivity.countDown() + activity.cancelAndJoin() + savedPeople.complete(Unit) + load.cancelAndJoin() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } + + @Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns + com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel)) + val switched = CompletableDeferred() + coEvery { repository.changeUser(server, mindel) } coAnswers { + switched.complete(Unit) + com.github.damontecres.wholphin.data.CurrentUser(server, mindel) + } + val navigation = mockk(relaxed = true) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val pending = CompletableDeferred() + val release = java.util.concurrent.CountDownLatch(1) + val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) + fake.beforeResponse = { + if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) { + pending.complete(Unit) + release.await() + } + } + try { + model.load(server, shared) + withTimeout(5_000) { pending.await() } + val shell = requireNotNull(session.state) + shell.key(ShellKey.Ok) + shell.enterDestination("discover") + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(1) + shell.key(ShellKey.Ok) + assertEquals("home", shell.route) + verify { navigation.replace(Destination.Home()) } + withTimeout(5_000) { switched.await() } + model.load(server, mindel) + requireNotNull(model.onForeground(server, mindel)).join() + release.countDown() + assertSame(shell, session.state) + assertEquals("mindel", model.signedInPerson) + assertEquals(mindel.id.toString(), shell.person.jellyfinUserId) + assertEquals("Mindel", shell.person.name) + assertEquals("mindel", conductarr.identity.value!!.operator!!.requester) + assertEquals("home", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index cc4d75d6..fde2ff9c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -81,6 +81,7 @@ class TitleReadFacadeTest { every { repository.currentServer } returns null every { repository.currentUser } returns null coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null + coEvery { items.getItems(any()) } returns successQueryResult(emptyList()) val context = mockk { every { filesDir } returns directory.root } facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), context) } @@ -103,6 +104,29 @@ class TitleReadFacadeTest { mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams))) } + @Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { items.getItems(any()) } returns successQueryResult( + listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42")))) + val page = facade.read(firstId, null, preferences) + assertEquals(42L, page.item.tmdbId) + assertEquals(firstId.toString(), page.watchTarget.id) + assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id) + FakeConductarr().use { fake -> + ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {} + ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId), + org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) } + assertEquals(listOf("header", "cast", "related"), screen.rows) + assertEquals("Sam Rockwell", screen.data.cast.single().name) + assertEquals(firstId.toString(), screen.target?.id) + assertTrue(screen.data.item.held) + } + } + } + @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) facade.read(firstId, null, preferences) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 8b11ee3a..ee29266b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -95,18 +95,6 @@ internal fun rememberDiscoverState( } } } - LaunchedEffect(state.title?.key, state) { - val title = state.title ?: return@LaunchedEffect - if (!fixtures && identity?.signedIn == true) { - val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV - identity.provider.detail(title.tmdb, media).collect { result -> - val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value } - if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy( - runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(), - seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title))) - } - } - } val result = if (fixtures) fixtureState ?: state else state LaunchedEffect(shell.route, result) { result.enterRoute(shell.route) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 3d182180..a9948c2a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -95,14 +95,14 @@ class WolfskinShellViewModel @Inject constructor( if (ConductarrPreference.fixtures) conductarr.close() else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() + session.onAction = { action -> handleAction(action, server, user) } if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { - val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } - val people = readShellPeople(context, saved.ifEmpty { listOf(user) }) session.serverId = server.id.toString() fixtureMode = ConductarrPreference.fixtures - session.state = ShellState(people, signedInPerson) { session.onAction(it) } + session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) } + val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } + session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) })) } - session.onAction = { action -> handleAction(action, server, user) } identityJob = viewModelScope.launch { if (!ConductarrPreference.fixtures) loadHousehold(user) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 73ebcd07..4b3a3a4a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor( /** An episode opens its series with that exact episode as both play and watched target. */ suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { val file = cacheFile(id) - val opened = api.userLibraryApi.getItem(id).content - val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened + val opened = readItem(id) + val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } @@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor( result.copy(trailerUrl = trailer) } + /** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */ + private suspend fun readItem(id: UUID): BaseItemDto { + val item = api.userLibraryApi.getItem(id).content + if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item + val metadata = try { + api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id, + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id } + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } ?: return item + return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) + } + /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } diff --git a/docker/README.md b/docker/README.md index db9676ad..fba9a5d9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -473,6 +473,15 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin logins; that does not create selectable TV logins. Late name reads preserve the open chooser's selected recipient and footer focus. +The launch picker publishes the current Jellyfin user and working navigation before +the saved-login database read. Saved people and Conductarr household metadata fill +that same shell later; slow activity or catalogue reads cannot hold its first usable +frame. Discover details navigate to the shared title page, including cast, performer +navigation and related titles, rather than the header-only design preview. Held +items whose detail omits ProviderIds or People get an explicit Jellyfin field read +before catalogue enrichment, without changing their playback identity. Scrolled +navigation draws an opaque band; the unscrolled hero treatment is unchanged. + Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from every non-Jellyfin artwork origin (scheme, host and port), including redirects; @@ -504,6 +513,13 @@ and a same-host/different-port origin with an authenticated upstream parent, and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers operator success plus failed/empty audiences, name-rule retention and foreground healing. +`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending +while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest` +covers omitted held-film provider ids with authenticated cast/related enrichment. +`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back +and writes native cast/related captures to `walk-fixes/`. +`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. + Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. Unheld trailers remain absent because the server contract has no trailer field. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 65143485..f0e2dabb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -169,7 +169,7 @@ class DiscoverState( fun openTitle(item: DiscoverTitle) { title = item; titleButton = 0 if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) - onPush("discover-title/${item.key}") + onPush("title/${android.net.Uri.encode(item.key)}") } fun openCollection(lane: DiscoverLane) { var titles = lane.fullTitles.associateBy { it.key } @@ -237,7 +237,7 @@ class DiscoverState( /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ override fun key(key: ShellKey): Boolean { if (overlay != null) { overlayKey(key); return true } - if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false + if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } if (title == null) collection?.let { if (key == ShellKey.Back) { collection = null; onPop(); return true } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 9042e3fe..1fedb8c7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") val density = LocalDensity.current.density - val band = remember(scrolled) { - if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f)) - else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) + val band = remember { + Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) } Box(modifier.fillMaxWidth().height(L.navBand)) { Canvas(Modifier.matchParentSize()) { - drawRect(band) + if (scrolled) drawRect(C.bg) else drawRect(band) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index ae66b19b..e5bd3c3a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -36,7 +36,7 @@ class DiscoverKeysTest { rule.setContent { WolfskinTheme { ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> - if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) + if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) } } } From afff383b75cbd2a8e5ebde32d7ebe9ccbc1671db Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 11:52:48 +0100 Subject: [PATCH 2/7] Read live shell state inside cached navigation entries --- .../wolfskin/tv/FakeConductarr.kt | 2 + .../wolfskin/tv/FirstSignInNavigationTest.kt | 122 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- docker/README.md | 9 ++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index c751b56e..04a8ac97 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -27,6 +27,7 @@ internal class FakeConductarr : Closeable { @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false @Volatile var beforeResponse: ((String) -> Unit)? = null + @Volatile var audiencesBody: String? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -66,6 +67,7 @@ internal class FakeConductarr : Closeable { } if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) + if (key == "audiences") audiencesBody?.let { return response(it) } if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt new file mode 100644 index 00000000..b0102a5b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -0,0 +1,122 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +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 androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.* +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.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class FirstSignInNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { + ConductarrPreference.initialize(rule.activity) + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.initialize(rule.activity) + WolfskinScreenPreference.enabled = true + val previousUrl = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val api = mockk() + val views = mockk() + val items = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) + for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { + coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( + List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) + } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( + HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) + try { + rule.setContent { + WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model, + homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + conductarr.identity.value?.signedIn == true && model.libraries.audiences != null + } + rule.onNodeWithText("Mindel").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionUp) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionCenter) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Desperate Housewives").assertDoesNotExist() + assertEquals(6, session.state!!.view.library!!.source.size) + assertFalse(session.state!!.person.hasAnime) + rule.onNodeWithText("Shows").performClick() + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + session.state!!.view.library?.source?.size == 29 + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Home").performClick() + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) + } finally { model.viewModelScope.cancel() } + } + } + ConductarrPreference.updateBaseUrl(previousUrl) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index a9948c2a..cfc1617b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -230,6 +230,8 @@ fun WolfskinApp( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinShellViewModel = hiltViewModel(), + homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = + { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } @@ -274,13 +276,16 @@ fun WolfskinApp( entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + // Cached entries must not retain the launch shell after sign-in completes. + val state = LocalShellState.current + val discover = LocalDiscoverState.current val route = when (destination) { is Destination.Home -> "home" is Destination.Wolfskin -> destination.route is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) else -> "pushed" } - LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } + LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { @@ -297,7 +302,7 @@ fun WolfskinApp( when { (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) + destination is Destination.Home -> homeContent(state, server, user, contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/docker/README.md b/docker/README.md index fba9a5d9..5e2b488e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -481,6 +481,10 @@ navigation and related titles, rather than the header-only design preview. Held items whose detail omits ProviderIds or People get an explicit Jellyfin field read before catalogue enrichment, without changing their playback identity. Scrolled navigation draws an opaque band; the unscrolled hero treatment is unchanged. +Cached `NavDisplay` entries read the current shell and Discover composition locals, +not the temporary launch shell captured when the entry was first created. Otherwise +the screen can retain skeleton people, disconnected tab actions and an open picker +even after the real session is ready. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -519,6 +523,11 @@ covers omitted held-film provider ids with authenticated cast/related enrichment `DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back and writes native cast/related captures to `walk-fixes/`. `NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. +`FirstSignInNavigationTest` starts the full app host with no existing shell and +immediate signed-in replies, then continues as Mindel and renders her six-film +Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also +checks that Continue closes the real session picker and selecting Home does not +reopen it. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. From 22850632ec1e355cf096f35b56709e70af833c23 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:03:20 +0100 Subject: [PATCH 3/7] Restrict shell route and Back handling to the current entry --- .../wolfskin/tv/FirstSignInNavigationTest.kt | 14 +++++++++++--- .../org/justinvisser/wolfskin/tv/WolfskinApp.kt | 7 +++++-- docker/README.md | 2 ++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index b0102a5b..63442159 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -105,9 +105,17 @@ class FirstSignInNavigationTest { assertEquals(6, session.state!!.view.library!!.source.size) assertFalse(session.state!!.person.hasAnime) rule.onNodeWithText("Shows").performClick() - rule.waitUntil(10_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - session.state!!.view.library?.source?.size == 29 + try { + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + session.state!!.view.library?.source?.size == 29 + } + } catch (timeout: ComposeTimeoutException) { + val shell = session.state!! + throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " + + "movies=${shell.rememberedView("movies").library?.source?.size}, " + + "shows=${shell.rememberedView("shows").library?.source?.size}/" + + "${shell.rememberedView("shows").library?.status}", timeout) } rule.onNodeWithTag("library-grid").assertIsDisplayed() rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index cfc1617b..52a68beb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -285,11 +285,14 @@ fun WolfskinApp( is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) else -> "pushed" } - LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) } + val isCurrent = navigationManager.backStack.lastOrNull() == destination + LaunchedEffect(destination, state, isCurrent) { + if (isCurrent && !destination.fullScreen) state.enterDestination(route) + } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { - BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } + BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null diff --git a/docker/README.md b/docker/README.md index 5e2b488e..bd1667dc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -485,6 +485,8 @@ Cached `NavDisplay` entries read the current shell and Discover composition loca not the temporary launch shell captured when the entry was first created. Otherwise the screen can retain skeleton people, disconnected tab actions and an open picker even after the real session is ready. +Only the current navigation entry may set the shared route or handle Back; +outgoing entries can remain composed during a transition. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from From 5a84666074cb68a5c9e901b1bb6f4ccfd7402ba7 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:11:42 +0100 Subject: [PATCH 4/7] Synchronize fresh-sign-in navigation and restore test globals --- .../wolfskin/tv/FirstSignInNavigationTest.kt | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 63442159..aa1faf95 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -26,6 +26,8 @@ import org.jellyfin.sdk.api.operations.ItemsApi import org.jellyfin.sdk.api.operations.UserViewsApi import org.jellyfin.sdk.model.api.* import org.junit.Assert.* +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,13 +43,29 @@ import org.robolectric.annotation.Config @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") class FirstSignInNavigationTest { @get:Rule val rule = createAndroidComposeRule() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + + @Before fun captureGlobalPreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + rule.mainClock.autoAdvance = true + } + + @After fun restoreGlobalPreferences() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + rule.mainClock.autoAdvance = true + } @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { - ConductarrPreference.initialize(rule.activity) ConductarrPreference.updateFixtures(false) - WolfskinScreenPreference.initialize(rule.activity) WolfskinScreenPreference.enabled = true - val previousUrl = ConductarrPreference.baseUrl FakeConductarr().use { fake -> fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" ConductarrPreference.updateBaseUrl(fake.url) @@ -95,6 +113,7 @@ class FirstSignInNavigationTest { pressKey(Key.DirectionRight) pressKey(Key.DirectionCenter) } + rule.waitForIdle() rule.waitUntil(10_000) { Shadows.shadowOf(Looper.getMainLooper()).idle() rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() @@ -105,10 +124,12 @@ class FirstSignInNavigationTest { assertEquals(6, session.state!!.view.library!!.source.size) assertFalse(session.state!!.person.hasAnime) rule.onNodeWithText("Shows").performClick() + rule.waitForIdle() try { - rule.waitUntil(10_000) { + rule.waitUntil(timeoutMillis = 15_000) { Shadows.shadowOf(Looper.getMainLooper()).idle() - session.state!!.view.library?.source?.size == 29 + rule.onAllNodes(hasTestTag("library-grid") and + hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty() } } catch (timeout: ComposeTimeoutException) { val shell = session.state!! @@ -118,13 +139,14 @@ class FirstSignInNavigationTest { "${shell.rememberedView("shows").library?.status}", timeout) } rule.onNodeWithTag("library-grid").assertIsDisplayed() + assertEquals(29, session.state!!.view.library!!.source.size) rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() rule.onNodeWithText("Home").performClick() + rule.waitForIdle() rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) } finally { model.viewModelScope.cancel() } } } - ConductarrPreference.updateBaseUrl(previousUrl) } } From 53b8358317c4f8697624edb92472ce9b30db96a9 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:25:51 +0100 Subject: [PATCH 5/7] Hide Anime destinations without visible library titles --- .../wolfskin/tv/AnimeAvailabilityTest.kt | 101 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 12 +++ docker/README.md | 8 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt new file mode 100644 index 00000000..e5d0dfa5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -0,0 +1,101 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +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.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeAvailabilityTest { + private var previousFixtures = false + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext()) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) { + for (count in listOf(0, 1)) FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token) + conductarr.select("${server.id}:${user.id}", token, fake.url) + conductarr.authenticate() + val identity = conductarr.identity.value!! + val audiences = identity.provider.audiences()!!.filter { + it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty()) + } + val names = audiences.flatMap { it.libraries.orEmpty() }.distinct() + assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty()) + val views = mockk() + val items = mockk() + val api = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map { + BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + }) + val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) } + coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) + val libraries = LibraryReadFacade(api) + val session = WolfskinSession().apply { + state = ShellState(listOf(ShellPerson(key, key, key.take(1), false, user.id.toString())), key) + } + val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) + val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), + destinationTotals = mapOf("anime" to count)) + assertEquals("An empty Anime audience must not create a Home shelf", count > 0, + buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" }) + } finally { model.viewModelScope.cancel() } + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 52a68beb..b654df48 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -123,12 +123,19 @@ class WolfskinShellViewModel @Inject constructor( if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } + val hasAnime = try { + libraries.hasAnimeTitles(user.id) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false + } + if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return val updated = session.state?.people.orEmpty().map { person -> val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } val current = person.jellyfinUserId == user.id.toString() person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, name = if (current) identity.operator!!.name else profile?.name ?: person.name, - hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime) + hasAnime = if (current) hasAnime else person.hasAnime) } session.state?.updatePeople(updated) signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index cc9497f1..775235f2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { } } + /** Audience metadata names a grouping; only this user's visible titles make Anime available. */ + suspend fun hasAnimeTitles(userId: UUID): Boolean { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() + for (library in libraries) { + if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue + val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty() + if (titles.isNotEmpty()) return true + } + return false + } + /** A destination spans every matching accessible library; containers never become titles. */ fun readLibrary(userId: UUID, destination: String): Flow> = flow { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> diff --git a/docker/README.md b/docker/README.md index bd1667dc..b1a5993d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -494,6 +494,11 @@ every non-Jellyfin artwork origin (scheme, host and port), including redirects; current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps the existing mapping or Jellyfin name rule. Returning to the foreground retries household metadata without rebuilding the selected identity. +An Anime audience grants a library grouping, not proof of owned titles. Household +refresh enables Anime only after a one-title Jellyfin read confirms visible series +in an accessible Anime library for the signed-in user. This runs after the live +picker is published; failures retain that user's last confirmed availability. +Empty Anime Home shelves remain omitted. | Screen seam | Production supplier | | --- | --- | @@ -530,6 +535,9 @@ immediate signed-in replies, then continues as Mindel and renders her six-film Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also checks that Continue closes the real session picker and selecting Home does not reopen it. +`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared +and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or +Home shelf, while a real visible series enables both. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. From 9e15dbee8c97dede2fba370693f09eaba736f403 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:33:10 +0100 Subject: [PATCH 6/7] Preserve navigation focus when Anime availability changes --- .../org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt | 7 ++++++- docker/README.md | 2 ++ .../java/org/justinvisser/wolfskin/design/ShellState.kt | 5 ++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index e5d0dfa5..35e45892 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -82,13 +82,18 @@ class AnimeAvailabilityTest { includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) val libraries = LibraryReadFacade(api) val session = WolfskinSession().apply { - state = ShellState(listOf(ShellPerson(key, key, key.take(1), false, user.id.toString())), key) + state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply { + pickerVisible = false + focusNav(tabs.size) + } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) + assertEquals("The avatar must remain focused when Anime appears or disappears", + session.state!!.tabs.size, session.state!!.navIndex) val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), destinationTotals = mapOf("anime" to count)) assertEquals("An empty Anime audience must not create a Home shelf", count > 0, diff --git a/docker/README.md b/docker/README.md index b1a5993d..1996dd3d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -499,6 +499,8 @@ refresh enables Anime only after a one-title Jellyfin read confirms visible seri in an accessible Anime library for the signed-in user. This runs after the live picker is published; failures retain that user's last confirmed availability. Empty Anime Home shelves remain omitted. +When availability changes, focus follows the avatar or a surviving destination; +removing Anime never leaves an out-of-range nav selection. | Screen seam | Production supplier | | --- | --- | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 4afd8415..e54c7ed8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -50,14 +50,17 @@ class ShellState( var people by mutableStateOf(people) private set - /** Refresh household labels without discarding this person's navigation memory. */ + /** Refresh household data without losing valid nav focus or this person's view memory. */ fun updatePeople(value: List) { + val focusedTab = tabs.getOrNull(navIndex) + val avatarFocused = navIndex == tabs.size val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } ?: value.firstOrNull { it.key == person.key } people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) preparePersonMemory() + navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set From 6e0f9952cf13225635a70767812c03674de5a652 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 13:11:08 +0100 Subject: [PATCH 7/7] Reconcile removed Anime roots without discarding pushed pages --- .../wolfskin/tv/AnimeRootRemovalTest.kt | 111 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- docker/README.md | 10 +- .../wolfskin/design/ShellState.kt | 8 +- 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt new file mode 100644 index 00000000..1af82b5c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +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 io.mockk.coEvery +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeRootRemovalTest { + private var previousFixtures = false + private var previousUrl = "" + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext()) + previousFixtures = ConductarrPreference.fixtures + previousUrl = ConductarrPreference.baseUrl + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking { + val title = Destination.Wolfskin("title/tv%3A123") + val person = Destination.Wolfskin("person/456") + val scenarios = listOf(emptyList() to false, emptyList() to true, + listOf(title) to false, listOf(title, person) to false) + for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val context = ApplicationProvider.getApplicationContext() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns null + var hasTitles = true + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, user) + requireNotNull(model.onForeground(server, user)).join() + val shell = requireNotNull(session.state) + assertTrue(shell.person.hasAnime) + shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("anime")) + shell.key(ShellKey.Ok) + assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList()) + for (page in pushed) { + navigation.navigateTo(page) + shell.enterDestination(page.route) + } + if (avatarFocused) shell.focusNav(shell.tabs.size) + + hasTitles = false + requireNotNull(model.onForeground(server, user)).join() + assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs) + assertEquals("Home must replace the missing root, not the current pushed page", + listOf(Destination.Home()) + pushed, navigation.backStack.toList()) + assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route) + assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab) + if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex) + + shell.key(ShellKey.Back) + if (pushed.isEmpty()) { + assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused) + assertEquals("home", shell.route) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + if (!avatarFocused) assertEquals(0, shell.navIndex) + } else { + assertEquals("Back pops only the current page and retains the replacement Home root", + listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList()) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index b654df48..a4859d21 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -181,7 +181,14 @@ class WolfskinShellViewModel @Inject constructor( private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { when (action) { - is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) + is ShellAction.Destination -> { + val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) + when { + action.route == "settings" -> navigation.navigateTo(target) + action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target + else -> navigation.replace(target) + } + } ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) diff --git a/docker/README.md b/docker/README.md index 1996dd3d..d7463830 100644 --- a/docker/README.md +++ b/docker/README.md @@ -485,8 +485,9 @@ Cached `NavDisplay` entries read the current shell and Discover composition loca not the temporary launch shell captured when the entry was first created. Otherwise the screen can retain skeleton people, disconnected tab actions and an open picker even after the real session is ready. -Only the current navigation entry may set the shared route or handle Back; -outgoing entries can remain composed during a transition. +Only the current navigation entry may set the shared route or enable the outer +host's Back handler; outgoing entries can remain composed during a transition. +Nested title handlers retain their existing lifecycle-based behaviour. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -500,7 +501,10 @@ in an accessible Anime library for the signed-in user. This runs after the live picker is published; failures retain that user's last confirmed availability. Empty Anime Home shelves remain omitted. When availability changes, focus follows the avatar or a surviving destination; -removing Anime never leaves an out-of-range nav selection. +removing Anime never leaves an out-of-range nav selection. If Anime was the root, +both the shell and navigation stack replace it with Home while retaining pushed +title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus, +and the real navigation stack with one or two pushed pages. | Screen seam | Production supplier | | --- | --- | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index e54c7ed8..c770a3ab 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { - data class Destination(val route: String) : ShellAction + data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction data class Person(val person: ShellPerson) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction @@ -59,8 +59,14 @@ class ShellState( people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) + val removedRoot = rootRoute !in tabs + if (removedRoot) { + if (route == rootRoute) route = "home" + rootRoute = "home" + } preparePersonMemory() navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab + if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true)) } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set