From 9355288f79b908b12d08f589f9716916fb0e1fa8 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 16:49:53 +0100 Subject: [PATCH] Recover failed person switches without changing identity --- .../wholphin/data/ServerRepository.kt | 35 +-- .../services/ServerSavedSessionTest.kt | 31 +++ .../wolfskin/tv/OfflinePersonSwitchTest.kt | 220 ++++++++++++++++++ .../wolfskin/tv/PersonCommitTest.kt | 12 - .../wolfskin/tv/TitleEntryBackTest.kt | 85 ++++--- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 36 ++- .../wolfskin/tv/WolfskinSetupContent.kt | 13 +- .../wolfskin/tv/WolfskinSetupViewModel.kt | 7 +- docker/README.md | 12 + .../wolfskin/design/RecoveryToast.kt | 1 + .../wolfskin/design/ShellState.kt | 16 +- 11 files changed, 400 insertions(+), 68 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 814b5287..ecd344c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -104,21 +104,30 @@ class ServerRepository throw IllegalStateException("User is not part of the server") } Timber.v("Changing user to ${user.name} on ${server.url}") + val previous = _current.value apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val (updatedServer, userDto) = readSession(server) - var updatedUser = - user.copy( - id = userDto.id, - name = userDto.name, - ) - serverDao.addOrUpdateServer(updatedServer) - updatedUser = serverDao.addOrUpdateUser(updatedUser) + try { + val (updatedServer, userDto) = readSession(server) + var updatedUser = + user.copy( + id = userDto.id, + name = userDto.name, + ) + serverDao.addOrUpdateServer(updatedServer) + updatedUser = serverDao.addOrUpdateUser(updatedUser) - val currentUser = CurrentUser(updatedServer, updatedUser) - mostRecentServerProvider.save(currentUser) - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) - return@withContext currentUser + val currentUser = CurrentUser(updatedServer, updatedUser) + currentCoroutineContext().ensureActive() + mostRecentServerProvider.save(currentUser) + currentCoroutineContext().ensureActive() + _current.value = currentUser + _currentUserDto.value = ServerUserConfig(userDto) + return@withContext currentUser + } catch (exception: Exception) { + // A failed or cancelled selection must not lend its token to the still-signed-in person. + apiClient.update(baseUrl = previous?.server?.url, accessToken = previous?.user?.accessToken) + throw exception + } } /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt index 2724cc61..afead8c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt @@ -10,6 +10,9 @@ import com.github.damontecres.wholphin.ui.successResponse import io.mockk.* import java.util.UUID import kotlinx.coroutines.CompletableDeferred +import java.io.IOException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.test.StandardTestDispatcher @@ -106,6 +109,34 @@ class ServerSavedSessionTest { assertNull(repository.currentUserDto) } + @Test fun failedPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { + verifyFailedSwitch(cancel = false) + } + + @Test fun cancelledPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { + verifyFailedSwitch(cancel = true) + } + + private suspend fun verifyFailedSwitch(cancel: Boolean) { + var activeToken: String? = null + every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() } + val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser + val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person", accessToken = "other-token")) + coEvery { users.getCurrentUser() } coAnswers { + if (cancel) awaitCancellation() else throw IOException("offline") + } + if (cancel) withTimeoutOrNull(1_000) { repository.changeUser(server, selected) } + else { + try { + repository.changeUser(server, selected) + fail("Offline selection must fail") + } catch (_: IOException) { } + } + assertEquals(first, repository.current.value) + assertEquals(user.accessToken, activeToken) + coVerify(exactly = 0) { recent.save(any()) } + } + @Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) { val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser val entered = CompletableDeferred() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt new file mode 100644 index 00000000..2ca0bd3b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt @@ -0,0 +1,220 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.Snapshot +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.MainContent +import com.github.damontecres.wholphin.data.CurrentUser +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.* +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.io.IOException +import java.time.Duration +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** A failed person switch must leave an escapable picker without changing the authenticated person. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class OfflinePersonSwitchTest { + @get:Rule val rule = createAndroidComposeRule() + private val fake = FakeConductarr() + private val conductarr = ConductarrSession(MemoryCacheStore()) + private val server = JellyfinServer(UUID.randomUUID(), "Household", fake.url, null) + private val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN).map { (name, token) -> + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) + } + private val original = CurrentUser(server, users.first()) + private val current = MutableStateFlow(original) + private val repository = mockk(relaxed = true) + private val navigation = NavigationManager() + private val setup = mockk(relaxed = true) + private val stack = mutableStateListOf(SetupDestination.AppContent(original)) + private val session = WolfskinSession() + private val setupModel = WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) + private lateinit var model: WolfskinShellViewModel + private var oldFixtures = false + private var oldScreens = true + private var oldUrl = "" + + @Before fun configure() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + oldFixtures = ConductarrPreference.fixtures + oldScreens = WolfskinScreenPreference.enabled + oldUrl = ConductarrPreference.baseUrl + ConductarrPreference.updateFixtures(false) + ConductarrPreference.updateBaseUrl(fake.url) + WolfskinScreenPreference.enabled = true + every { repository.current } returns current + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, users[1]) } throws IOException("offline") + every { setup.navigateTo(any()) } answers { stack[0] = firstArg() } + val backdrop = mockk(relaxed = true) + every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE) + model = WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, backdrop, + mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), + org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)) + val screensaver = mockk(relaxed = true) + every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { + MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), backdrop, + screensaver, current, mockk(relaxed = true), setupInterceptor = { destination, modifier -> + if (destination is SetupDestination.AppContent) { + WolfskinApp(server, destination.current.user, navigation, + UserPreferences(AppPreferences.getDefaultInstance(), null), modifier, model, + homeContent = { shell, _, user, contentModifier -> + val home = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) } + HomeScreen(shell, home, contentModifier) {} + }) + true + } else WolfskinSetupContent(destination, modifier, setupModel) + }) + } + await { session.state?.people?.size == 2 && model.signedInPerson == "justin" } + } + + @After fun close() { + if (::model.isInitialized) model.viewModelScope.cancel() + setupModel.viewModelScope.cancel() + conductarr.close() + fake.close() + ConductarrPreference.updateFixtures(oldFixtures) + ConductarrPreference.updateBaseUrl(oldUrl) + WolfskinScreenPreference.enabled = oldScreens + } + + @Test fun failedAvatarSwitchBackDismissesToOriginalNavThenExits() { + failSwitch(fromAvatar = true) + back() + assertFalse("Dismissing a reopened picker must not exit yet", rule.activity.isFinishing) + assertEquals("Back must dismiss to the still-authenticated app", SetupDestination.AppContent(original), stack.single()) + assertFalse("Back cannot remain trapped on Who's watching", session.state!!.pickerVisible) + assertEquals(users.first().id.toString(), session.state!!.person.jellyfinUserId) + assertTrue(session.state!!.navFocused) + assertEquals(original, current.value) + back() + assertTrue("The following Back from nav must exit", rule.activity.isFinishing) + } + + @Test fun failedAvatarSwitchExplainsThatTheServerDidNotAnswer() { + failSwitch(fromAvatar = true) + rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed() + rule.onRoot().captureRoboImage(File(System.getProperty("wolfskin.captureDirectory"), "offline-switch-sdk${android.os.Build.VERSION.SDK_INT}.png")) + assertEquals(original, current.value) + } + + @Test fun failedLaunchPickerSelectionBackExitsWithoutChangingIdentity() { + failSwitch(fromAvatar = false) + back() + assertTrue("A launch picker must exit rather than expose the old person's Home", rule.activity.isFinishing) + assertEquals(original, current.value) + } + + @Test fun coldSetupPickerBackExitsWithoutRestoringTheSignedInEntry() { + rule.runOnIdle { stack[0] = SetupDestination.UserList(server) } + await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithText("Who’s watching?").assertIsDisplayed() + back() + assertTrue("Cold setup picker Back must exit, even with an old identity in memory", rule.activity.isFinishing) + assertEquals(original, current.value) + } + + @Test fun failedSetupSelectionEscapeStillExitsThePicker() { + val response = CompletableDeferred() + coEvery { repository.changeUser(server, users[1]) } coAnswers { + response.await() + throw IOException("offline") + } + rule.runOnIdle { stack[0] = SetupDestination.UserList(server) } + await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionRight) } + assertEquals(1, setupModel.picker!!.pickerIndex) + rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionCenter) } + response.complete(Unit) + await { setupModel.stage == WolfskinSetupViewModel.Stage.Unavailable } + back() + await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.Escape) } + assertTrue("Escape and system Back must share the setup picker's exit", rule.activity.isFinishing) + assertEquals(original, current.value) + } + + @Test fun unansweredSwitchCannotKeepInputCoveredIndefinitely() { + var entered = false + coEvery { repository.changeUser(server, users[1]) } coAnswers { + entered = true + awaitCancellation() + } + press(listOf(Key.DirectionRight, Key.DirectionCenter)) + await { entered } + rule.runOnIdle { Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(15)) } + rule.mainClock.advanceTimeBy(11_000) + rule.waitForIdle() + assertNull("An unanswered switch must release its input cover within 15 seconds", session.personCommit) + await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed() + back() + assertTrue(rule.activity.isFinishing) + assertEquals(original, current.value) + } + + private fun failSwitch(fromAvatar: Boolean) { + if (fromAvatar) { + press(listOf(Key.DirectionCenter)) + await { session.personCommit == null && session.state?.pickerVisible == false } + press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) + } + press(listOf(Key.DirectionRight, Key.DirectionCenter)) + await { session.personCommit == null && (session.state?.pickerVisible == true || setupModel.stage == WolfskinSetupViewModel.Stage.Picker) } + assertEquals("A failed switch cannot select another authenticated person", original, current.value) + } + + private fun press(keys: List) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } + } + + private fun back() { + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + rule.mainClock.advanceTimeBy(32) + rule.waitForIdle() + } + + private fun await(condition: () -> Boolean) { + rule.waitUntil(10_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + condition() + } + rule.waitForIdle() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt index 66f184cd..295b7799 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.takahirom.roborazzi.captureRoboImage import io.mockk.* import java.io.File -import java.io.IOException import kotlinx.coroutines.CompletableDeferred import org.junit.* import org.junit.Assert.* @@ -147,17 +146,6 @@ class PersonCommitTest { verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } } - @Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() { - host = PolishAppHost(rule) - host.mount(fixtures = false) - coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed") - rule.mainClock.autoAdvance = false - choose(listOf(Key.DirectionRight, Key.DirectionCenter)) - awaitSwitchWork() - finishTimeline() - assertNull(host.session.personCommit) - verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } - } private fun choose(keys: List): PersonCommit { press(keys) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index 7e7a92dd..d3b5992d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -11,8 +11,6 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot @@ -53,11 +51,14 @@ class TitleEntryBackTest { private lateinit var viewModel: WolfskinTitleViewModel private lateinit var conductarr: ConductarrSession private var homeModel: WolfskinHomeViewModel? = null + private var shellModel: WolfskinShellViewModel? = null private var previousFixtures: Boolean? = null @After fun dispose() { + rule.mainClock.autoAdvance = true if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() homeModel?.viewModelScope?.cancel() + shellModel?.viewModelScope?.cancel() previousFixtures?.let(ConductarrPreference::updateFixtures) if (::conductarr.isInitialized) conductarr.close() } @@ -67,51 +68,59 @@ class TitleEntryBackTest { @Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true) @Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true) - @Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false) - @Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true) + @Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1) + @Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1) + @Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8) + @Test fun sharedHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared") - private fun verifyHomeViewport(reduced: Boolean) { + private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin") { val navigation = NavigationManager() - val viewer = ShellPerson("justin", "Justin", "J", true) - val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false } - val snapshot = HomeFixture(rule.activity).snapshot(viewer.key) + val session = WolfskinSession() conductarr = ConductarrSession(MemoryCacheStore()) + ConductarrPreference.initialize(rule.activity) previousFixtures = ConductarrPreference.fixtures ConductarrPreference.updateFixtures(true) val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession()) - homeModel = source + val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = person.replaceFirstChar { it.uppercase() }, serverId = server.id, accessToken = null) viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true), + mockk(relaxed = true), mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), + org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)) + shellModel = app val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { - val onBack = rememberWolfskinBackHandler(shell, navigation) - NavDisplay(backStack = navigation.backStack, onBack = onBack, - entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), - entryProvider = { destination -> NavEntry(destination) { - val active = navigation.backStack.lastOrNull() == destination - val route = (destination as? Destination.Wolfskin)?.route ?: "home" - LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) } - ScreenEntry(shell, route, active) { - if (destination is Destination.Home) ShellSurface(shell) { modifier -> - HomeDestination(shell, server, user, modifier, source) - } else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { + WolfskinApp(server, user, navigation, preferences, viewModel = app, + homeContent = { shell, homeServer, homeUser, modifier -> + val source = androidx.lifecycle.viewmodel.compose.viewModel { + WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) } - } }) - } } } + homeModel = source + HomeDestination(shell, homeServer, homeUser, modifier, source) + }, + titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) }) + } } rule.waitUntil(30_000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() Shadows.shadowOf(Looper.getMainLooper()).idle() - source.snapshot.rows.any { it.id == "cw" } && !shell.navFocused + session.state != null && app.signedInPerson == person } - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } + rule.waitUntil(30_000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + homeModel?.snapshot?.rows?.any { it.id == "cw" } == true && session.personCommit == null + } + val shell = requireNotNull(session.state) + val snapshot = requireNotNull(homeModel).snapshot + rule.onRoot().performKeyInput { repeat(depth) { pressKey(Key.DirectionDown) } } rule.mainClock.advanceTimeBy(1000) rule.waitForIdle() val home = shell.rememberedView("home") val tag = "home-card:${snapshot.rows[home.row].id}:0" val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-${if (reduced) "reduced" else "animated"}").apply { mkdirs() } + val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-${if (reduced) "reduced" else "animated"}-$depth").apply { mkdirs() } rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath) rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } rule.waitUntil(30_000) { @@ -119,12 +128,24 @@ class TitleEntryBackTest { viewModel.title?.data?.status == TitleReadStatus.Ready } rule.mainClock.advanceTimeBy(1000) - dispatchBack(predictive = false) - rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + rule.runOnIdle { + rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) + rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) + } + if (navigation.backStack.size > 1) dispatchBack(predictive = false) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + rule.runOnIdle { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + } + rule.mainClock.advanceTimeBy(64) rule.waitForIdle() rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath) - assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }) - assertEquals(bounds, rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot) + val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } + val afterBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + assertEquals("card bounds before=$bounds after=$afterBounds; list before=$before after=$after", before, after) + assertEquals(bounds, afterBounds) } @Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() { 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 eb50cddb..3644f070 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -77,6 +77,7 @@ class WolfskinSession @Inject constructor() { internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} var personCommit by mutableStateOf(null) + var setupMessage: String? = null val playback = org.justinvisser.wolfskin.design.PlaybackStart() } @@ -228,7 +229,7 @@ class WolfskinShellViewModel @Inject constructor( ShellAction.Pop -> navigation.goBack() ShellAction.Exit -> navigation.requestExit() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) - is ShellAction.Person -> selectPerson(action.person, server) + is ShellAction.Person -> selectPerson(action, server, user) ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryRequests -> viewModelScope.launch { if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) @@ -238,7 +239,9 @@ class WolfskinShellViewModel @Inject constructor( } } - private fun selectPerson(person: ShellPerson, server: JellyfinServer) { + private fun selectPerson(selection: ShellAction.Person, server: JellyfinServer, user: JellyfinUser) { + val person = selection.person + val previousPerson = signedInPerson val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let { org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key) } @@ -275,7 +278,14 @@ class WolfskinShellViewModel @Inject constructor( } catch (exception: Exception) { if (exception is kotlinx.coroutines.CancellationException) throw exception coroutineContext.ensureActive() - val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } + val open = { + val current = repository.current.value + if (current?.server?.id == server.id && current.user.id == user.id) { + signedInPerson = previousPerson + session.state?.restorePersonPicker(previousPerson, selection.fromLaunch) + session.state?.toast = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable + } else openSwitchRecovery(server) + } if (commit == null) open() else commit.fallback = open } } @@ -284,6 +294,18 @@ class WolfskinShellViewModel @Inject constructor( } } } + + /** A stuck replacement cannot retain an inert picker or expose a possibly changed identity. */ + fun expirePersonSwitch(server: JellyfinServer) { + personSwitchJob?.cancel() + session.personCommit = null + openSwitchRecovery(server) + } + + private fun openSwitchRecovery(server: JellyfinServer) { + session.setupMessage = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable + setupNavigation.navigateTo(SetupDestination.UserList(server)) + } } /** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */ @@ -293,6 +315,8 @@ fun handOffDestination(group: String): Destination = when (group) { else -> Destination.Settings(PreferenceScreenOption.BASIC) } +private const val PERSON_SWITCH_TIMEOUT_MILLIS = 10_000L + private val LocalShellState = compositionLocalOf { error("Wolfskin destination requires the shell") } /** The one upstream hook preserves destination decorators and full-screen hand-offs. */ @@ -341,6 +365,12 @@ fun WolfskinApp( val onBack = rememberWolfskinBackHandler(state, navigationManager) val session = viewModel.session val commit = session.personCommit + LaunchedEffect(commit) { + if (commit != null) { + kotlinx.coroutines.delay(PERSON_SWITCH_TIMEOUT_MILLIS) + if (session.personCommit === commit) viewModel.expirePersonSwitch(server) + } + } LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) { if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) { session.personCommit = null diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt index c7b16b3f..a4509c52 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt @@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.setup.PinEntryDialog import kotlinx.coroutines.launch +import com.github.damontecres.wholphin.ui.findActivity import org.justinvisser.wolfskin.design.ShellAction import org.justinvisser.wolfskin.design.ShellSurface import org.justinvisser.wolfskin.design.WolfskinTheme @@ -69,6 +70,12 @@ internal fun WolfskinSetupScreen( model: WolfskinSetupViewModel = hiltViewModel(), ) { val scope = rememberCoroutineScope() + val activity = checkNotNull(LocalContext.current.findActivity()) + val onBack = { + if (model.pinUser != null) model.dismissPin() + else if (model.stage == Stage.Picker || model.stage == Stage.Loading || model.stage == Stage.Unconfigured || model.busy || model.picker == null) activity.finishAndRemoveTask() + else model.returnToPicker() + } val hint = (destination as? SetupDestination.UserList)?.server LaunchedEffect(destination) { model.load(hint) } val retrySetup: () -> Unit = { scope.launch { model.retry() } } @@ -76,13 +83,11 @@ internal fun WolfskinSetupScreen( when (action) { is ShellAction.Person -> scope.launch { model.select(action.person) } is ShellAction.Destination -> model.showSettings() + ShellAction.Exit -> onBack() else -> Unit } } - BackHandler { - if (model.pinUser != null) model.dismissPin() - else model.returnToPicker() - } + BackHandler(onBack = onBack) WolfskinTheme { val picker = model.picker if (model.stage == Stage.Picker && picker != null) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt index 6d1caffb..4e7a06aa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt @@ -84,6 +84,9 @@ class WolfskinSetupViewModel @Inject constructor( picker = ShellState(people, people.first().key) { onAction(it) } stage = Stage.Picker } + }.also { + picker?.toast = session.setupMessage + session.setupMessage = null } /** Retry the startup server/profile recovery, never the failed saved-token request. */ @@ -111,7 +114,7 @@ class WolfskinSetupViewModel @Inject constructor( /** Protected saved users must authenticate before changeUser is allowed to run. */ internal suspend fun select(person: ShellPerson) { - picker?.pickerVisible = true + picker?.apply { pickerVisible = true; pickerLaunch = true } if (busy) return val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return when { @@ -137,7 +140,7 @@ class WolfskinSetupViewModel @Inject constructor( internal fun returnToPicker() { if (busy || picker == null) return - picker?.pickerVisible = true + picker?.apply { pickerVisible = true; pickerLaunch = true } stage = Stage.Picker } diff --git a/docker/README.md b/docker/README.md index 0942bb83..6d2d20ee 100644 --- a/docker/README.md +++ b/docker/README.md @@ -216,6 +216,18 @@ require-login profiles still go through the existing protected sign-in flow. cannot replace a different selected person. Name revalidation updates only the name column and reloads the saved user, preserving newer settings and PINs. +A failed person switch reopens the picker on the still-authenticated person and +shows “The household server didn’t answer.” Back from a reopened avatar picker +dismisses to that person's navigation; the next Back exits. Back from a launch or +setup picker exits instead of exposing a previous person's content. Settings, +login and error panels return to the picker first; Back can exit a busy setup +request. PIN and require-login selection rules are unchanged. + +Failed or cancelled Jellyfin switches restore the previous API token as well as +retaining the signed-in identity. A person-switch cover has a ten-second deadline; +an unfinished switch/replacement falls back to the setup picker with a message, +without choosing a different authenticated person. + Jellyfin, Conductarr, artwork, playback and crash-report clients share one `PersistentDns` instance through the application graph. It keeps system-ordered IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt index 30e19c61..2ab89023 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -68,4 +68,5 @@ object RecoveryMessage { const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again." const val TitleActionUnavailable = "Can’t update this title right now." + const val PersonSwitchUnavailable = "The household server didn’t answer. Choose a profile or try again." } 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 e875fbc2..398e8a72 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 @@ -12,7 +12,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, val preservePushed: Boolean = false) : ShellAction - data class Person(val person: ShellPerson) : ShellAction + data class Person(val person: ShellPerson, val fromLaunch: Boolean) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction data object Exit : ShellAction @@ -212,6 +212,7 @@ class ShellState( } private fun choosePerson(selected: ShellPerson) { + val fromLaunch = pickerLaunch person = selected pickerVisible = false pickerLaunch = false @@ -223,7 +224,18 @@ class ShellState( requestsVisible = false preparePersonMemory() views[person.key to route] = RememberedView() - onAction(ShellAction.Person(selected)) + onAction(ShellAction.Person(selected, fromLaunch)) + } + + /** A failed switch may reopen only the identity that the integration still has authenticated. */ + fun restorePersonPicker(personKey: String, fromLaunch: Boolean) { + person = people.first { it.key == personKey } + preparePersonMemory() + pickerIndex = people.indexOf(person) + gearFocused = false + navFocused = false + pickerLaunch = fromLaunch + pickerVisible = true } private fun closePicker() {