Preserve Home Back viewport and recover offline person switches #84

Merged
justinzeus merged 2 commits from fm/tv-r2-walkfix into fm/tv-round2 2026-11-08 19:20:29 +01:00
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.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,25 +103,36 @@ class ServerRepository
user: JellyfinUser, user: JellyfinUser,
): CurrentUser = ): CurrentUser =
withContext(ioDispatcher) { withContext(ioDispatcher) {
if (server.id != user.serverId) { userChangeMutex.withLock {
throw IllegalStateException("User is not part of the server") 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) Timber.v("Changing user to ${user.name} on ${server.url}")
val (updatedServer, userDto) = readSession(server) val previous = _current.value
var updatedUser = apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
user.copy( try {
id = userDto.id, val (updatedServer, userDto) = readSession(server)
name = userDto.name, var updatedUser =
) user.copy(
serverDao.addOrUpdateServer(updatedServer) id = userDto.id,
updatedUser = serverDao.addOrUpdateUser(updatedUser) name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
val currentUser = CurrentUser(updatedServer, updatedUser) val currentUser = CurrentUser(updatedServer, updatedUser)
mostRecentServerProvider.save(currentUser) currentCoroutineContext().ensureActive()
_current.value = currentUser mostRecentServerProvider.save(currentUser)
_currentUserDto.value = ServerUserConfig(userDto) currentCoroutineContext().ensureActive()
return@withContext currentUser _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. */ /** 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 io.mockk.*
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import java.io.IOException
import kotlinx.coroutines.awaitCancellation
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
@ -106,6 +111,64 @@ class ServerSavedSessionTest {
assertNull(repository.currentUserDto) 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) { @Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val entered = CompletableDeferred<Unit>() 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 com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.* import io.mockk.*
import java.io.File import java.io.File
import java.io.IOException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import org.junit.* import org.junit.*
import org.junit.Assert.* import org.junit.Assert.*
@ -147,17 +146,6 @@ class PersonCommitTest {
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } 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 { private fun choose(keys: List<Key>): PersonCommit {
press(keys) press(keys)

View file

@ -5,14 +5,14 @@ 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
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey 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.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
@ -21,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
@ -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. */ /** 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 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
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
homeModel?.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) previousFixtures?.let(ConductarrPreference::updateFixtures)
if (::conductarr.isInitialized) conductarr.close() if (::conductarr.isInitialized) conductarr.close()
} }
@ -67,51 +94,104 @@ class TitleEntryBackTest {
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true) @Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true) @Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false) @Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1)
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true) @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 navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", true) val session = WolfskinSession()
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false } val heldReads = if (paintLoading) StandardTestDispatcher().also {
val snapshot = HomeFixture(rule.activity).snapshot(viewer.key) previousHomeIo = WholphinDispatchers.IO
WholphinDispatchers.IO = it
} else null
conductarr = ConductarrSession(MemoryCacheStore()) conductarr = ConductarrSession(MemoryCacheStore())
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true) 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 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 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 source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession())
homeModel = source
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),
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 preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { val current = CurrentUser(server, user)
val onBack = rememberWolfskinBackHandler(shell, navigation) val live = MutableStateFlow<CurrentUser?>(current)
NavDisplay(backStack = navigation.backStack, onBack = onBack, val setup = androidx.compose.runtime.mutableStateListOf<SetupDestination>(SetupDestination.AppContent(current))
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), val trailer = mockk<TrailerReturnState>(relaxed = true)
entryProvider = { destination -> NavEntry(destination) { val screensaver = mockk<ScreensaverService>(relaxed = true)
val active = navigation.backStack.lastOrNull() == destination every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
val route = (destination as? Destination.Wolfskin)?.route ?: "home" rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) {
LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) } val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
ScreenEntry(shell, route, active) { androidx.compose.runtime.LaunchedEffect(inputMode) { inputMode.requestInputMode(androidx.compose.ui.input.InputMode.Keyboard) }
if (destination is Destination.Home) ShellSurface(shell) { modifier -> MainContent(setup, navigation, preferences, backdrop, screensaver, live, trailer,
HomeDestination(shell, server, user, modifier, source) modifier = Modifier.fillMaxSize(),
} else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true) 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) { rule.waitUntil(30_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle() 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.mainClock.advanceTimeBy(1000)
rule.waitForIdle() rule.waitForIdle()
val home = shell.rememberedView("home") val home = shell.rememberedView("home")
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-${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().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) {
@ -119,12 +199,32 @@ class TitleEntryBackTest {
viewModel.title?.data?.status == TitleReadStatus.Ready viewModel.title?.data?.status == TitleReadStatus.Ready
} }
rule.mainClock.advanceTimeBy(1000) rule.mainClock.advanceTimeBy(1000)
dispatchBack(predictive = false) rule.mainClock.autoAdvance = false
rule.mainClock.advanceTimeBy(1000) 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.waitForIdle()
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)
assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }) val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
assertEquals(bounds, 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(bounds, afterBounds)
} }
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() { @Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {

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
@ -77,6 +78,7 @@ class WolfskinSession @Inject constructor() {
internal var fixtureMode: Boolean? = null internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {} var onAction: (ShellAction) -> Unit = {}
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null) var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
var setupMessage: String? = null
val playback = org.justinvisser.wolfskin.design.PlaybackStart() val playback = org.justinvisser.wolfskin.design.PlaybackStart()
} }
@ -228,7 +230,7 @@ class WolfskinShellViewModel @Inject constructor(
ShellAction.Pop -> navigation.goBack() ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit() ShellAction.Exit -> navigation.requestExit()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) 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.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> viewModelScope.launch { ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) 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 { 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) 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) { } catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive() 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 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. */ /** 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) 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") } private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") }
/** The one upstream hook preserves destination decorators and full-screen hand-offs. */ /** The one upstream hook preserves destination decorators and full-screen hand-offs. */
@ -341,6 +369,12 @@ fun WolfskinApp(
val onBack = rememberWolfskinBackHandler(state, navigationManager) val onBack = rememberWolfskinBackHandler(state, navigationManager)
val session = viewModel.session val session = viewModel.session
val commit = session.personCommit 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) { LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) { if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
session.personCommit = null 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.components.EditTextBox
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.github.damontecres.wholphin.ui.findActivity
import org.justinvisser.wolfskin.design.ShellAction import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellSurface import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.design.WolfskinTheme
@ -69,6 +70,12 @@ internal fun WolfskinSetupScreen(
model: WolfskinSetupViewModel = hiltViewModel(), model: WolfskinSetupViewModel = hiltViewModel(),
) { ) {
val scope = rememberCoroutineScope() 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 val hint = (destination as? SetupDestination.UserList)?.server
LaunchedEffect(destination) { model.load(hint) } LaunchedEffect(destination) { model.load(hint) }
val retrySetup: () -> Unit = { scope.launch { model.retry() } } val retrySetup: () -> Unit = { scope.launch { model.retry() } }
@ -76,13 +83,11 @@ internal fun WolfskinSetupScreen(
when (action) { when (action) {
is ShellAction.Person -> scope.launch { model.select(action.person) } is ShellAction.Person -> scope.launch { model.select(action.person) }
is ShellAction.Destination -> model.showSettings() is ShellAction.Destination -> model.showSettings()
ShellAction.Exit -> onBack()
else -> Unit else -> Unit
} }
} }
BackHandler { BackHandler(onBack = onBack)
if (model.pinUser != null) model.dismissPin()
else model.returnToPicker()
}
WolfskinTheme { WolfskinTheme {
val picker = model.picker val picker = model.picker
if (model.stage == Stage.Picker && picker != null) { 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) } picker = ShellState(people, people.first().key) { onAction(it) }
stage = Stage.Picker stage = Stage.Picker
} }
}.also {
picker?.toast = session.setupMessage
session.setupMessage = null
} }
/** Retry the startup server/profile recovery, never the failed saved-token request. */ /** 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. */ /** Protected saved users must authenticate before changeUser is allowed to run. */
internal suspend fun select(person: ShellPerson) { internal suspend fun select(person: ShellPerson) {
picker?.pickerVisible = true picker?.apply { pickerVisible = true; pickerLaunch = true }
if (busy) return if (busy) return
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
when { when {
@ -137,7 +140,7 @@ class WolfskinSetupViewModel @Inject constructor(
internal fun returnToPicker() { internal fun returnToPicker() {
if (busy || picker == null) return if (busy || picker == null) return
picker?.pickerVisible = true picker?.apply { pickerVisible = true; pickerLaunch = true }
stage = Stage.Picker 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 cannot replace a different selected person. Name revalidation updates only the
name column and reloads the saved user, preserving newer settings and PINs. 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 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
IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). 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 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
@ -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. 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

@ -68,4 +68,5 @@ object RecoveryMessage {
const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." 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 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 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. */ /** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction { sealed interface ShellAction {
data class Destination(val route: String, val preservePushed: Boolean = false) : 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 class HandOff(val group: String) : ShellAction
data object Pop : ShellAction data object Pop : ShellAction
data object Exit : ShellAction data object Exit : ShellAction
@ -212,6 +212,7 @@ class ShellState(
} }
private fun choosePerson(selected: ShellPerson) { private fun choosePerson(selected: ShellPerson) {
val fromLaunch = pickerLaunch
person = selected person = selected
pickerVisible = false pickerVisible = false
pickerLaunch = false pickerLaunch = false
@ -223,7 +224,18 @@ class ShellState(
requestsVisible = false requestsVisible = false
preparePersonMemory() preparePersonMemory()
views[person.key to route] = RememberedView() 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() { private fun closePicker() {

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,8 +104,13 @@ fun ShellSurface(
if (state.pickerVisible) { if (state.pickerVisible) {
WhosWatching(state) WhosWatching(state)
} else { } else {
FocusRingHost { // Logical-focus screens own their scroll position; TV's 30% focus pivot must not recenter it.
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) 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) NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
ComingInSheet(state) ComingInSheet(state)