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)
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:
parent
9355288f79
commit
72849f0734
7 changed files with 215 additions and 52 deletions
|
|
@ -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,33 +103,35 @@ 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}")
|
||||
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)
|
||||
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)
|
||||
currentCoroutineContext().ensureActive()
|
||||
mostRecentServerProvider.save(currentUser)
|
||||
currentCoroutineContext().ensureActive()
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = ServerUserConfig(userDto)
|
||||
return@withContext currentUser
|
||||
} catch (exception: Exception) {
|
||||
// A failed or cancelled selection must not lend its token to the still-signed-in person.
|
||||
apiClient.update(baseUrl = previous?.server?.url, accessToken = previous?.user?.accessToken)
|
||||
throw exception
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ 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
|
||||
|
|
@ -117,6 +119,36 @@ class ServerSavedSessionTest {
|
|||
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() }
|
||||
|
|
|
|||
|
|
@ -132,6 +132,25 @@ class OfflinePersonSwitchTest {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ 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
|
||||
|
|
@ -19,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
|
||||
|
|
@ -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. */
|
||||
@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()
|
||||
}
|
||||
|
|
@ -72,10 +98,21 @@ class TitleEntryBackTest {
|
|||
@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, 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 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
|
||||
|
|
@ -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)
|
||||
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),
|
||||
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))
|
||||
shellModel = app
|
||||
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) {
|
||||
WolfskinApp(server, user, navigation, preferences, viewModel = app,
|
||||
homeContent = { shell, homeServer, homeUser, modifier ->
|
||||
val source = androidx.lifecycle.viewmodel.compose.viewModel {
|
||||
WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
|
||||
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) })
|
||||
}
|
||||
homeModel = source
|
||||
HomeDestination(shell, homeServer, homeUser, modifier, source)
|
||||
},
|
||||
titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) })
|
||||
true
|
||||
})
|
||||
} }
|
||||
rule.waitUntil(30_000) {
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
|
|
@ -106,6 +162,20 @@ class TitleEntryBackTest {
|
|||
session.state != null && app.signedInPerson == person
|
||||
}
|
||||
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()
|
||||
|
|
@ -120,7 +190,8 @@ class TitleEntryBackTest {
|
|||
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-$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().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.waitUntil(30_000) {
|
||||
|
|
@ -144,6 +215,14 @@ class TitleEntryBackTest {
|
|||
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -280,11 +281,14 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
coroutineContext.ensureActive()
|
||||
val open = {
|
||||
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
|
||||
session.state?.restorePersonPicker(previousPerson, selection.fromLaunch)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
setup picker exits instead of exposing a previous person's content. Settings,
|
||||
login and error panels return to the picker first; Back can exit a busy setup
|
||||
request. PIN and require-login selection rules are unchanged.
|
||||
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. 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,
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
|
@ -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.
|
||||
|
||||
`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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue