Recover failed person switches without changing identity

This commit is contained in:
justinzeus 2026-11-08 16:49:53 +01:00
parent c21d17dfdf
commit 9355288f79
11 changed files with 400 additions and 68 deletions

View file

@ -104,21 +104,30 @@ class ServerRepository
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)
val (updatedServer, userDto) = readSession(server)
var updatedUser =
user.copy(
id = userDto.id,
name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
try {
val (updatedServer, userDto) = readSession(server)
var updatedUser =
user.copy(
id = userDto.id,
name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
val currentUser = CurrentUser(updatedServer, updatedUser)
mostRecentServerProvider.save(currentUser)
_current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto)
return@withContext currentUser
val currentUser = CurrentUser(updatedServer, updatedUser)
currentCoroutineContext().ensureActive()
mostRecentServerProvider.save(currentUser)
currentCoroutineContext().ensureActive()
_current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto)
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
}
}
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */

View file

@ -10,6 +10,9 @@ import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import java.io.IOException
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
@ -106,6 +109,34 @@ class ServerSavedSessionTest {
assertNull(repository.currentUserDto)
}
@Test fun failedPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) {
verifyFailedSwitch(cancel = false)
}
@Test fun cancelledPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) {
verifyFailedSwitch(cancel = true)
}
private suspend fun verifyFailedSwitch(cancel: Boolean) {
var activeToken: String? = null
every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() }
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person", accessToken = "other-token"))
coEvery { users.getCurrentUser() } coAnswers {
if (cancel) awaitCancellation() else throw IOException("offline")
}
if (cancel) withTimeoutOrNull(1_000) { repository.changeUser(server, selected) }
else {
try {
repository.changeUser(server, selected)
fail("Offline selection must fail")
} catch (_: IOException) { }
}
assertEquals(first, repository.current.value)
assertEquals(user.accessToken, activeToken)
coVerify(exactly = 0) { recent.save(any()) }
}
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
val entered = CompletableDeferred<Unit>()

View file

@ -0,0 +1,220 @@
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 failedLaunchPickerSelectionBackExitsWithoutChangingIdentity() {
failSwitch(fromAvatar = false)
back()
assertTrue("A launch picker must exit rather than expose the old person's Home", rule.activity.isFinishing)
assertEquals(original, current.value)
}
@Test fun coldSetupPickerBackExitsWithoutRestoringTheSignedInEntry() {
rule.runOnIdle { stack[0] = SetupDestination.UserList(server) }
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
back()
assertTrue("Cold setup picker Back must exit, even with an old identity in memory", rule.activity.isFinishing)
assertEquals(original, current.value)
}
@Test fun failedSetupSelectionEscapeStillExitsThePicker() {
val response = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, users[1]) } coAnswers {
response.await()
throw IOException("offline")
}
rule.runOnIdle { stack[0] = SetupDestination.UserList(server) }
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
rule.mainClock.advanceTimeBy(1_000)
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionRight) }
assertEquals(1, setupModel.picker!!.pickerIndex)
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionCenter) }
response.complete(Unit)
await { setupModel.stage == WolfskinSetupViewModel.Stage.Unavailable }
back()
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.Escape) }
assertTrue("Escape and system Back must share the setup picker's exit", rule.activity.isFinishing)
assertEquals(original, current.value)
}
@Test fun unansweredSwitchCannotKeepInputCoveredIndefinitely() {
var entered = false
coEvery { repository.changeUser(server, users[1]) } coAnswers {
entered = true
awaitCancellation()
}
press(listOf(Key.DirectionRight, Key.DirectionCenter))
await { entered }
rule.runOnIdle { Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(15)) }
rule.mainClock.advanceTimeBy(11_000)
rule.waitForIdle()
assertNull("An unanswered switch must release its input cover within 15 seconds", session.personCommit)
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed()
back()
assertTrue(rule.activity.isFinishing)
assertEquals(original, current.value)
}
private fun failSwitch(fromAvatar: Boolean) {
if (fromAvatar) {
press(listOf(Key.DirectionCenter))
await { session.personCommit == null && session.state?.pickerVisible == false }
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
}
press(listOf(Key.DirectionRight, Key.DirectionCenter))
await { session.personCommit == null && (session.state?.pickerVisible == true || setupModel.stage == WolfskinSetupViewModel.Stage.Picker) }
assertEquals("A failed switch cannot select another authenticated person", original, current.value)
}
private fun press(keys: List<Key>) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
}
private fun back() {
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(32)
rule.waitForIdle()
}
private fun await(condition: () -> Boolean) {
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
condition()
}
rule.waitForIdle()
}
}

View file

@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import kotlinx.coroutines.CompletableDeferred
import org.junit.*
import org.junit.Assert.*
@ -147,17 +146,6 @@ class PersonCommitTest {
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
@Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() {
host = PolishAppHost(rule)
host.mount(fixtures = false)
coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed")
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
awaitSwitchWork()
finishTimeline()
assertNull(host.session.personCommit)
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
private fun choose(keys: List<Key>): PersonCommit {
press(keys)

View file

@ -11,8 +11,6 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
@ -53,11 +51,14 @@ class TitleEntryBackTest {
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
@After fun dispose() {
rule.mainClock.autoAdvance = true
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
homeModel?.viewModelScope?.cancel()
shellModel?.viewModelScope?.cancel()
previousFixtures?.let(ConductarrPreference::updateFixtures)
if (::conductarr.isInitialized) conductarr.close()
}
@ -67,51 +68,59 @@ class TitleEntryBackTest {
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false)
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true)
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1)
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1)
@Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8)
@Test fun sharedHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared")
private fun verifyHomeViewport(reduced: Boolean) {
private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin") {
val navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", true)
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false }
val snapshot = HomeFixture(rule.activity).snapshot(viewer.key)
val session = WolfskinSession()
conductarr = ConductarrSession(MemoryCacheStore())
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true)
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession())
homeModel = source
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = person.replaceFirstChar { it.uppercase() }, serverId = server.id, accessToken = null)
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true),
mockk(relaxed = true), mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true))
shellModel = app
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
val onBack = rememberWolfskinBackHandler(shell, navigation)
NavDisplay(backStack = navigation.backStack, onBack = onBack,
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> NavEntry(destination) {
val active = navigation.backStack.lastOrNull() == destination
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) }
ScreenEntry(shell, route, active) {
if (destination is Destination.Home) ShellSurface(shell) { modifier ->
HomeDestination(shell, server, user, modifier, source)
} else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true)
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)
}
} })
} } }
homeModel = source
HomeDestination(shell, homeServer, homeUser, modifier, source)
},
titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) })
} }
rule.waitUntil(30_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
source.snapshot.rows.any { it.id == "cw" } && !shell.navFocused
session.state != null && app.signedInPerson == person
}
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
homeModel?.snapshot?.rows?.any { it.id == "cw" } == true && session.personCommit == null
}
val shell = requireNotNull(session.state)
val snapshot = requireNotNull(homeModel).snapshot
rule.onRoot().performKeyInput { repeat(depth) { pressKey(Key.DirectionDown) } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val home = shell.rememberedView("home")
val tag = "home-card:${snapshot.rows[home.row].id}:0"
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-${if (reduced) "reduced" else "animated"}").apply { mkdirs() }
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-${if (reduced) "reduced" else "animated"}-$depth").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) {
@ -119,12 +128,24 @@ class TitleEntryBackTest {
viewModel.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(1000)
dispatchBack(predictive = false)
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle {
rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
}
if (navigation.backStack.size > 1) dispatchBack(predictive = false)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
rule.runOnIdle {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
}
rule.mainClock.advanceTimeBy(64)
rule.waitForIdle()
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset })
assertEquals(bounds, rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot)
val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
val afterBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
assertEquals("card bounds before=$bounds after=$afterBounds; list before=$before after=$after", before, after)
assertEquals(bounds, afterBounds)
}
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {

View file

@ -77,6 +77,7 @@ class WolfskinSession @Inject constructor() {
internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {}
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
var setupMessage: String? = null
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
}
@ -228,7 +229,7 @@ class WolfskinShellViewModel @Inject constructor(
ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server)
is ShellAction.Person -> selectPerson(action, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
@ -238,7 +239,9 @@ class WolfskinShellViewModel @Inject constructor(
}
}
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
private fun selectPerson(selection: ShellAction.Person, server: JellyfinServer, user: JellyfinUser) {
val person = selection.person
val previousPerson = signedInPerson
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
}
@ -275,7 +278,14 @@ class WolfskinShellViewModel @Inject constructor(
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive()
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
val open = {
val current = repository.current.value
if (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)
}
if (commit == null) open() else commit.fallback = open
}
}
@ -284,6 +294,18 @@ class WolfskinShellViewModel @Inject constructor(
}
}
}
/** A stuck replacement cannot retain an inert picker or expose a possibly changed identity. */
fun expirePersonSwitch(server: JellyfinServer) {
personSwitchJob?.cancel()
session.personCommit = null
openSwitchRecovery(server)
}
private fun openSwitchRecovery(server: JellyfinServer) {
session.setupMessage = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable
setupNavigation.navigateTo(SetupDestination.UserList(server))
}
}
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
@ -293,6 +315,8 @@ fun handOffDestination(group: String): Destination = when (group) {
else -> Destination.Settings(PreferenceScreenOption.BASIC)
}
private const val PERSON_SWITCH_TIMEOUT_MILLIS = 10_000L
private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") }
/** The one upstream hook preserves destination decorators and full-screen hand-offs. */
@ -341,6 +365,12 @@ fun WolfskinApp(
val onBack = rememberWolfskinBackHandler(state, navigationManager)
val session = viewModel.session
val commit = session.personCommit
LaunchedEffect(commit) {
if (commit != null) {
kotlinx.coroutines.delay(PERSON_SWITCH_TIMEOUT_MILLIS)
if (session.personCommit === commit) viewModel.expirePersonSwitch(server)
}
}
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
session.personCommit = null

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
import kotlinx.coroutines.launch
import com.github.damontecres.wholphin.ui.findActivity
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
@ -69,6 +70,12 @@ internal fun WolfskinSetupScreen(
model: WolfskinSetupViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val activity = checkNotNull(LocalContext.current.findActivity())
val onBack = {
if (model.pinUser != null) model.dismissPin()
else if (model.stage == Stage.Picker || model.stage == Stage.Loading || model.stage == Stage.Unconfigured || model.busy || model.picker == null) activity.finishAndRemoveTask()
else model.returnToPicker()
}
val hint = (destination as? SetupDestination.UserList)?.server
LaunchedEffect(destination) { model.load(hint) }
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
@ -76,13 +83,11 @@ internal fun WolfskinSetupScreen(
when (action) {
is ShellAction.Person -> scope.launch { model.select(action.person) }
is ShellAction.Destination -> model.showSettings()
ShellAction.Exit -> onBack()
else -> Unit
}
}
BackHandler {
if (model.pinUser != null) model.dismissPin()
else model.returnToPicker()
}
BackHandler(onBack = onBack)
WolfskinTheme {
val picker = model.picker
if (model.stage == Stage.Picker && picker != null) {

View file

@ -84,6 +84,9 @@ class WolfskinSetupViewModel @Inject constructor(
picker = ShellState(people, people.first().key) { onAction(it) }
stage = Stage.Picker
}
}.also {
picker?.toast = session.setupMessage
session.setupMessage = null
}
/** Retry the startup server/profile recovery, never the failed saved-token request. */
@ -111,7 +114,7 @@ class WolfskinSetupViewModel @Inject constructor(
/** Protected saved users must authenticate before changeUser is allowed to run. */
internal suspend fun select(person: ShellPerson) {
picker?.pickerVisible = true
picker?.apply { pickerVisible = true; pickerLaunch = true }
if (busy) return
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
when {
@ -137,7 +140,7 @@ class WolfskinSetupViewModel @Inject constructor(
internal fun returnToPicker() {
if (busy || picker == null) return
picker?.pickerVisible = true
picker?.apply { pickerVisible = true; pickerLaunch = true }
stage = Stage.Picker
}