Merge pull request 'Preserve Home Back viewport and recover offline person switches' (#84) from fm/tv-r2-walkfix into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-08 19:20:29 +01:00
commit 017ad0268e
12 changed files with 578 additions and 83 deletions

View file

@ -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<CurrentUser?>(null)
val current: StateFlow<CurrentUser?> = _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. */

View file

@ -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<Unit>()
val release = CompletableDeferred<Unit>()
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<Unit>()

View file

@ -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<ComponentActivity>()
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<CurrentUser?>(original)
private val repository = mockk<ServerRepository>(relaxed = true)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val stack = mutableStateListOf<SetupDestination>(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<BackdropService>(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<ScreensaverService>(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<Unit>()
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<Key>) {
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()
}
}

View file

@ -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<Key>): PersonCommit {
press(keys)

View file

@ -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<ComponentActivity>()
private var systemAnimationScale = 1f
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(
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<BackdropService>(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<CurrentUser?>(current)
val setup = androidx.compose.runtime.mutableStateListOf<SetupDestination>(SetupDestination.AppContent(current))
val trailer = mockk<TrailerReturnState>(relaxed = true)
val screensaver = mockk<ScreensaverService>(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() {

View file

@ -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<org.justinvisser.wolfskin.design.PersonCommit?>(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<ShellState> { 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

View file

@ -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) {

View file

@ -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
}

View file

@ -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,

View file

@ -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."
}

View file

@ -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() {

View file

@ -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)