Preserve TV Back viewports and person-switch authority
All checks were successful
review/delta-opus Tier C delta review: 0 blockers; 4 non-blocking notes (N1-N4)
gate/round2-candidate Full suite 1308/1308 and Opt 1.1.2/71 checks on this exact head (data/tv-r2-gate5/result.json)

This commit is contained in:
justinzeus 2026-11-08 18:37:55 +01:00
parent 9355288f79
commit 72849f0734
7 changed files with 215 additions and 52 deletions

View file

@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
@ -51,6 +53,7 @@ class ServerRepository
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val mostRecentServerProvider: MostRecentServerProvider, private val mostRecentServerProvider: MostRecentServerProvider,
) { ) {
private val userChangeMutex = Mutex()
private var _current = MutableStateFlow<CurrentUser?>(null) private var _current = MutableStateFlow<CurrentUser?>(null)
val current: StateFlow<CurrentUser?> = _current val current: StateFlow<CurrentUser?> = _current
@ -100,6 +103,7 @@ class ServerRepository
user: JellyfinUser, user: JellyfinUser,
): CurrentUser = ): CurrentUser =
withContext(ioDispatcher) { withContext(ioDispatcher) {
userChangeMutex.withLock {
if (server.id != user.serverId) { if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
} }
@ -122,13 +126,14 @@ class ServerRepository
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
_current.value = currentUser _current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto) _currentUserDto.value = ServerUserConfig(userDto)
return@withContext currentUser currentUser
} catch (exception: Exception) { } catch (exception: Exception) {
// A failed or cancelled selection must not lend its token to the still-signed-in person. // 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) apiClient.update(baseUrl = previous?.server?.url, accessToken = previous?.user?.accessToken)
throw exception throw exception
} }
} }
}
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */ /** Restore only an unprotected saved login; the launch picker must not wait for the network. */
suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null) suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null)

View file

@ -15,6 +15,8 @@ import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -117,6 +119,36 @@ class ServerSavedSessionTest {
verifyFailedSwitch(cancel = true) 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) { private suspend fun verifyFailedSwitch(cancel: Boolean) {
var activeToken: String? = null var activeToken: String? = null
every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() } every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() }

View file

@ -132,6 +132,25 @@ class OfflinePersonSwitchTest {
assertEquals(original, current.value) 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() { @Test fun failedLaunchPickerSelectionBackExitsWithoutChangingIdentity() {
failSwitch(fromAvatar = false) failSwitch(fromAvatar = false)
back() back()

View file

@ -5,6 +5,8 @@ import android.os.Build
import android.os.Looper import android.os.Looper
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.BackEventCompat import androidx.activity.BackEventCompat
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
@ -19,15 +21,28 @@ import java.io.File
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay 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.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager 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.ui.nav.Destination
import com.github.damontecres.wholphin.util.WholphinDispatchers
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.cancel 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.After
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Rule import org.junit.Rule
@ -44,21 +59,32 @@ import org.robolectric.annotation.GraphicsMode
/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */ /** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */
@RunWith(RobolectricTestRunner::class) @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) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class TitleEntryBackTest { 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 viewModel: WolfskinTitleViewModel
private lateinit var conductarr: ConductarrSession private lateinit var conductarr: ConductarrSession
private var homeModel: WolfskinHomeViewModel? = null private var homeModel: WolfskinHomeViewModel? = null
private var shellModel: WolfskinShellViewModel? = null private var shellModel: WolfskinShellViewModel? = null
private var previousFixtures: Boolean? = null private var previousFixtures: Boolean? = null
private var previousHomeIo: CoroutineDispatcher? = null
private var previousLeanback: Boolean? = null
@After fun dispose() { @After fun dispose() {
rule.mainClock.autoAdvance = true rule.mainClock.autoAdvance = true
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
homeModel?.viewModelScope?.cancel() homeModel?.viewModelScope?.cancel()
shellModel?.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) previousFixtures?.let(ConductarrPreference::updateFixtures)
if (::conductarr.isInitialized) conductarr.close() if (::conductarr.isInitialized) conductarr.close()
} }
@ -72,10 +98,21 @@ class TitleEntryBackTest {
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1) @Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1)
@Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8) @Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8)
@Test fun sharedHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared") @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, depth: Int, person: String = "justin") { 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 navigation = NavigationManager()
val session = WolfskinSession() val session = WolfskinSession()
val heldReads = if (paintLoading) StandardTestDispatcher().also {
previousHomeIo = WholphinDispatchers.IO
WholphinDispatchers.IO = it
} else null
conductarr = ConductarrSession(MemoryCacheStore()) conductarr = ConductarrSession(MemoryCacheStore())
ConductarrPreference.initialize(rule.activity) ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures previousFixtures = ConductarrPreference.fixtures
@ -84,21 +121,40 @@ class TitleEntryBackTest {
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) 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, viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) 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), val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true),
mockk(relaxed = true), mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), backdrop, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true)) org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true))
shellModel = app shellModel = app
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
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) { rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) {
WolfskinApp(server, user, navigation, preferences, viewModel = app, val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
homeContent = { shell, homeServer, homeUser, modifier -> 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 { val source = androidx.lifecycle.viewmodel.compose.viewModel {
WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
} }
homeModel = source homeModel = source
HomeDestination(shell, homeServer, homeUser, modifier, source) HomeDestination(shell, homeServer, homeUser, contentModifier, source)
}, },
titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) }) titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) })
}
true
})
} } } }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
@ -106,6 +162,20 @@ class TitleEntryBackTest {
session.state != null && app.signedInPerson == person session.state != null && app.signedInPerson == person
} }
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } 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) { rule.waitUntil(30_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle() Shadows.shadowOf(Looper.getMainLooper()).idle()
@ -120,7 +190,8 @@ class TitleEntryBackTest {
val tag = "home-card:${snapshot.rows[home.row].id}:0" val tag = "home-card:${snapshot.rows[home.row].id}:0"
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-${if (reduced) "reduced" else "animated"}-$depth").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().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
@ -144,6 +215,14 @@ class TitleEntryBackTest {
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath) rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset } val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
val afterBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot 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("card bounds before=$bounds after=$afterBounds; list before=$before after=$after", before, after)
assertEquals(bounds, afterBounds) assertEquals(bounds, afterBounds)
} }

View file

@ -56,6 +56,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.PeopleStatus
import org.justinvisser.wolfskin.design.SettingsList import org.justinvisser.wolfskin.design.SettingsList
@ -280,11 +281,14 @@ class WolfskinShellViewModel @Inject constructor(
coroutineContext.ensureActive() coroutineContext.ensureActive()
val open = { val open = {
val current = repository.current.value val current = repository.current.value
if (current?.server?.id == server.id && current.user.id == user.id) { 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 signedInPerson = previousPerson
session.state?.restorePersonPicker(previousPerson, selection.fromLaunch) session.state?.restorePersonPicker(previousPerson, selection.fromLaunch)
session.state?.toast = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable session.state?.toast = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable
} else openSwitchRecovery(server) 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 if (commit == null) open() else commit.fallback = open
} }

View file

@ -221,12 +221,16 @@ shows “The household server didn’t answer.” Back from a reopened avatar pi
dismisses to that person's navigation; the next Back exits. Back from a launch or 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, 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 login and error panels return to the picker first; Back can exit a busy setup
request. PIN and require-login selection rules are unchanged. 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 Failed or cancelled Jellyfin switches restore the previous API token as well as
retaining the signed-in identity. A person-switch cover has a ten-second deadline; 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, an unfinished switch/replacement falls back to the setup picker with a message,
without choosing a different authenticated person. 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 Jellyfin, Conductarr, artwork, playback and crash-report clients share one
`PersistentDns` instance through the application graph. It keeps system-ordered `PersistentDns` instance through the application graph. It keeps system-ordered
@ -649,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 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 a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen. 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 Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. OK events cannot activate twice.
Between Home shelves, an unvisited row starts at its leftmost card. A visited row Between Home shelves, an unvisited row starts at its leftmost card. A visited row
@ -971,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. cache, 128 MiB disk cache and four image-read limit remain unchanged.
`HomeViewportTest` covers short/long poster, tile and wide rows, both row `HomeViewportTest` covers short/long poster, tile and wide rows, both row
directions, first focus and exact scroll/card bounds after native Back from the directions, first focus and exact scroll/card bounds after returning from the
actual title surface. `TitleEntryBackTest` also checks the production Home/title actual title surface. `TitleEntryBackTest` also checks production MainContent,
loaders through `NavDisplay` and its entry decorators on SDK 34 and 36, with the Home/title loaders, `NavDisplay` decorators and native system Back on SDK 34
animation and reduced motion. Native proof records real Bear season-three and 36. It models Leanback capabilities and keyboard input, animation/reduced
metadata through the title read facade; the proof-only harness is not part of motion, initial Home loading and system animation scale zero. Phone/touch-mode
the shipped tests or APK. 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, Episode lists request only ProviderIds and Overview. Next up reads only identity,
then hydrates the selected playback target through the existing single-item read, then hydrates the selected playback target through the existing single-item read,

View file

@ -1,6 +1,9 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background 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.focusable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRestorer 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.fillMaxSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L 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. */ /** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
Key.DirectionUp -> ShellKey.Up 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. */ /** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
fun ShellSurface( fun ShellSurface(
state: ShellState, state: ShellState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -96,9 +104,14 @@ fun ShellSurface(
if (state.pickerVisible) { if (state.pickerVisible) {
WhosWatching(state) WhosWatching(state)
} else { } else {
// 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 { FocusRingHost {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) 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) NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
ComingInSheet(state) ComingInSheet(state)
overlay() overlay()