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..91a939dd 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 @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -51,6 +53,7 @@ class ServerRepository @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val mostRecentServerProvider: MostRecentServerProvider, ) { + private val userChangeMutex = Mutex() private var _current = MutableStateFlow(null) val current: StateFlow = _current @@ -100,25 +103,36 @@ class ServerRepository user: JellyfinUser, ): CurrentUser = withContext(ioDispatcher) { - if (server.id != user.serverId) { - throw IllegalStateException("User is not part of the server") - } - Timber.v("Changing user to ${user.name} on ${server.url}") - 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) + userChangeMutex.withLock { + if (server.id != user.serverId) { + 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) + 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) + 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..1241a8cd 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,8 +10,13 @@ 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.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -106,6 +111,64 @@ class ServerSavedSessionTest { assertNull(repository.currentUserDto) } + @Test fun failedPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { + verifyFailedSwitch(cancel = false) + } + + @Test fun cancelledPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) { + verifyFailedSwitch(cancel = true) + } + + @Test fun cancelledOlderSwitchCannotReplaceTheNewerAuthenticatedToken() = runTest(dispatcher) { + var activeToken: String? = null + every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() } + repository.restoreSavedSession() + val older = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Older selection", accessToken = "older-token")) + val newer = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Newer selection", accessToken = "newer-token")) + val entered = CompletableDeferred() + val release = CompletableDeferred() + coEvery { recent.save(any()) } just Runs + coEvery { users.getCurrentUser() } coAnswers { + if (activeToken == older.accessToken) { + entered.complete(Unit) + withContext(NonCancellable) { release.await() } + throw IOException("late failed selection") + } + successResponse(dto.copy(id = newer.id, name = newer.name)) + } + val pending = async { runCatching { repository.changeUser(server, older) } } + entered.await() + pending.cancel() + val selected = async { repository.changeUser(server, newer) } + runCurrent() + release.complete(Unit) + pending.join() + val current = selected.await() + assertEquals(newer.id, current.user.id) + assertEquals(current, repository.current.value) + assertEquals(newer.accessToken, activeToken) + } + + 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..bd403f16 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/OfflinePersonSwitchTest.kt @@ -0,0 +1,239 @@ +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 failedAvatarSwitchRestoresOnlyTheOriginalHouseholdSession() { + failSwitch(fromAvatar = true) + await { conductarr.identity.value?.signedIn == true } + val identity = requireNotNull(conductarr.identity.value) + assertEquals("${server.id}:${users.first().id}", identity.person) + assertEquals("justin", identity.operator!!.requester) + assertEquals(original, current.value) + } + + @Test fun expiredTargetCredentialsStillOpenTheSignInGate() { + coEvery { repository.changeUser(server, users[1]) } throws org.jellyfin.sdk.api.client.exception.InvalidStatusException(401) + failSwitch(fromAvatar = true) + await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() } + rule.onNodeWithText("Mindel").performClick() + await { setupModel.stage == WolfskinSetupViewModel.Stage.Login } + rule.onNodeWithTag("profile-password").assertIsDisplayed() + 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..417c72e3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -5,14 +5,14 @@ import android.os.Build import android.os.Looper import androidx.activity.ComponentActivity import androidx.activity.BackEventCompat +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider 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 @@ -21,15 +21,28 @@ import java.io.File import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavEntry import androidx.navigation3.ui.NavDisplay +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.SignedInContent +import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository 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.services.BackdropService +import com.github.damontecres.wholphin.services.BackdropResult +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.WholphinDispatchers import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import java.util.UUID import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.After import org.junit.Assert.* import org.junit.Rule @@ -46,18 +59,32 @@ import org.robolectric.annotation.GraphicsMode /** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */ @RunWith(RobolectricTestRunner::class) -@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-television-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class TitleEntryBackTest { - @get:Rule val rule = createAndroidComposeRule() + private var systemAnimationScale = 1f + @get:Rule val rule = createAndroidComposeRule( + effectContext = object : androidx.compose.ui.MotionDurationScale { + override val scaleFactor get() = systemAnimationScale + }, + ) 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 + private var previousHomeIo: CoroutineDispatcher? = null + private var previousLeanback: Boolean? = null @After fun dispose() { + rule.mainClock.autoAdvance = true if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() homeModel?.viewModelScope?.cancel() + shellModel?.viewModelScope?.cancel() + previousHomeIo?.let { WholphinDispatchers.IO = it } + previousHomeIo = null + previousLeanback?.let { org.robolectric.Shadows.shadowOf(rule.activity.packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, it) } previousFixtures?.let(ConductarrPreference::updateFixtures) if (::conductarr.isInitialized) conductarr.close() } @@ -67,51 +94,104 @@ 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") + @Test fun loadingHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", paintLoading = true) + @Test fun systemAnimationsOffHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", systemScale = 0f) - private fun verifyHomeViewport(reduced: Boolean) { + private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin", paintLoading: Boolean = false, systemScale: Float = 1f) { + // A television qualifier alone does not advertise Leanback or leave Robolectric's touch mode. + systemAnimationScale = systemScale + val packageManager = rule.activity.packageManager + previousLeanback = packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK) + org.robolectric.Shadows.shadowOf(packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, true) 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() + val heldReads = if (paintLoading) StandardTestDispatcher().also { + previousHomeIo = WholphinDispatchers.IO + WholphinDispatchers.IO = it + } else null 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 backdrop = mockk(relaxed = true) + every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE) + val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true), + backdrop, 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) + val current = CurrentUser(server, user) + val live = MutableStateFlow(current) + val setup = androidx.compose.runtime.mutableStateListOf(SetupDestination.AppContent(current)) + val trailer = mockk(relaxed = true) + val screensaver = mockk(relaxed = true) + every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + androidx.compose.runtime.LaunchedEffect(inputMode) { inputMode.requestInputMode(androidx.compose.ui.input.InputMode.Keyboard) } + MainContent(setup, navigation, preferences, backdrop, screensaver, live, trailer, + modifier = Modifier.fillMaxSize(), + setupInterceptor = { destination, modifier -> + val entry = (destination as SetupDestination.AppContent).current + SignedInContent(entry, live, preferences.appPreferences.signInAutomatically, trailer, + { navigation.backStack.lastOrNull() }) { + WolfskinApp(entry.server, entry.user, navigation, preferences, modifier, app, + homeContent = { shell, homeServer, homeUser, contentModifier -> + val source = androidx.lifecycle.viewmodel.compose.viewModel { + WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) + } + homeModel = source + HomeDestination(shell, homeServer, homeUser, contentModifier, source) + }, + titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) }) } - } }) - } } } + 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) } + if (heldReads != null) { + rule.waitUntil(30_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + homeModel != null && session.personCommit == null + } + rule.onNodeWithTag("wolfskin-home").assertExists() + assertEquals(HomeAvailability.Loading, homeModel!!.snapshot.conductarr) + val loadingCaptures = File(System.getProperty("wolfskin.captureDirectory"), "home-loading-$person").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(loadingCaptures, "sdk${Build.VERSION.SDK_INT}.png").absolutePath) + heldReads.scheduler.runCurrent() + WholphinDispatchers.IO = previousHomeIo!! + previousHomeIo = null + } + 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 motion = if (systemScale == 0f) "system-off" else if (reduced) "reduced" else "animated" + val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-$motion-$depth${if (paintLoading) "-loading" else ""}").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 +199,32 @@ 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 + rule.mainClock.advanceTimeBy(1000) + Shadows.shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(1000)) + rule.waitForIdle() + rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-settled.png").absolutePath) + val settled = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } + val settledBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + assertEquals("settled card bounds before=$bounds after=$settledBounds; list before=$before after=$settled", before, settled) + assertEquals(bounds, settledBounds) + 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..79dcfca1 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import kotlinx.coroutines.withContext import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.SettingsList @@ -77,6 +78,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 +230,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 +240,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 +279,17 @@ 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 + val isAuthenticationFailure = exception is InvalidStatusException && (exception.status == 401 || exception.status == 403) + if (!isAuthenticationFailure && 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 + identityJob = viewModelScope.launch { retryConductarr(server, user) } + } else if (isAuthenticationFailure) setupNavigation.navigateTo(SetupDestination.UserList(server)) + else openSwitchRecovery(server) + } if (commit == null) open() else commit.fallback = open } } @@ -284,6 +298,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 +319,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 +369,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..72421201 100644 --- a/docker/README.md +++ b/docker/README.md @@ -216,6 +216,22 @@ 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, require-login and rejected-credential authentication gates remain +strict: an expired target sign-in still reaches the setup login form. + +Failed or cancelled Jellyfin switches restore the previous API token as well as +retaining the signed-in identity. Recovering the picker also reattaches only that +person's household client. 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. Repository user changes share +one transaction gate, so late cancellation/rollback cannot overwrite a newer +person's authenticated token. + 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). @@ -637,6 +653,10 @@ shell's logical focus without requests against uncomposed lazy items. Back preserves the viewport; returning to Home does not re-run focus-follow over a manually scrolled saved view. The next arrow re-enters visible content when scrolling moved focus off-screen. +Shell-owned Home, Library/See all, Discover and Settings/Updates lists use +Foundation's minimum-visibility bring-into-view policy, not TV's 30% focus pivot. +Restoring focus therefore leaves already-visible cards at their saved position; +native title/person and legacy handoffs retain their inherited policy. Pointing changes focus without activation, cards activate on key-up, and held OK events cannot activate twice. Between Home shelves, an unvisited row starts at its leftmost card. A visited row @@ -959,12 +979,15 @@ retain the approved frames except for the requested feedback changes: cache, 128 MiB disk cache and four image-read limit remain unchanged. `HomeViewportTest` covers short/long poster, tile and wide rows, both row -directions, first focus and exact scroll/card bounds after native Back from the -actual title surface. `TitleEntryBackTest` also checks the production Home/title -loaders through `NavDisplay` and its entry decorators on SDK 34 and 36, with -animation and reduced motion. Native proof records real Bear season-three -metadata through the title read facade; the proof-only harness is not part of -the shipped tests or APK. +directions, first focus and exact scroll/card bounds after returning from the +actual title surface. `TitleEntryBackTest` also checks production MainContent, +the Home/title loaders, `NavDisplay` decorators and native system Back on SDK 34 +and 36. It models Leanback capabilities and keyboard input, animation/reduced +motion, initial Home loading and system animation scale zero. Phone/touch-mode +tests miss the TV focus pivot; the zero-scale regression reproduced the exact +72-pixel drift before the shared policy fix. Native proof records real Bear +season-three metadata through the title read facade; the proof-only harness is +not part of the shipped tests or APK. Episode lists request only ProviderIds and Overview. Next up reads only identity, then hydrates the selected playback target through the existing single-item read, 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() { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 47687caa..9411fe70 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -1,6 +1,9 @@ package org.justinvisser.wolfskin.design import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.focusable import androidx.compose.foundation.focusGroup import androidx.compose.ui.focus.focusRestorer @@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -32,6 +36,9 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +@OptIn(ExperimentalFoundationApi::class) +private object MinimumScrollBringIntoViewSpec : BringIntoViewSpec + /** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */ fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { Key.DirectionUp -> ShellKey.Up @@ -51,6 +58,7 @@ fun ShellState.handleKey(event: KeyEvent): Boolean { /** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */ @Composable +@OptIn(ExperimentalFoundationApi::class) fun ShellSurface( state: ShellState, modifier: Modifier = Modifier, @@ -96,8 +104,13 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - FocusRingHost { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + // Logical-focus screens own their scroll position; TV's 30% focus pivot must not recenter it. + CompositionLocalProvider( + LocalBringIntoViewSpec provides if (handlesContentKeys) MinimumScrollBringIntoViewSpec else LocalBringIntoViewSpec.current, + ) { + FocusRingHost { + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + } } NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) ComingInSheet(state)