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,7 +104,9 @@ class ServerRepository
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
} }
Timber.v("Changing user to ${user.name} on ${server.url}") Timber.v("Changing user to ${user.name} on ${server.url}")
val previous = _current.value
apiClient.update(baseUrl = server.url, accessToken = user.accessToken) apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
try {
val (updatedServer, userDto) = readSession(server) val (updatedServer, userDto) = readSession(server)
var updatedUser = var updatedUser =
user.copy( user.copy(
@ -115,10 +117,17 @@ class ServerRepository
updatedUser = serverDao.addOrUpdateUser(updatedUser) updatedUser = serverDao.addOrUpdateUser(updatedUser)
val currentUser = CurrentUser(updatedServer, updatedUser) val currentUser = CurrentUser(updatedServer, updatedUser)
currentCoroutineContext().ensureActive()
mostRecentServerProvider.save(currentUser) mostRecentServerProvider.save(currentUser)
currentCoroutineContext().ensureActive()
_current.value = currentUser _current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto) _currentUserDto.value = ServerUserConfig(userDto)
return@withContext currentUser 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. */ /** 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 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.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -106,6 +109,34 @@ class ServerSavedSessionTest {
assertNull(repository.currentUserDto) 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) { @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,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 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

@ -11,8 +11,6 @@ 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
@ -53,11 +51,14 @@ class TitleEntryBackTest {
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
@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()
previousFixtures?.let(ConductarrPreference::updateFixtures) previousFixtures?.let(ConductarrPreference::updateFixtures)
if (::conductarr.isInitialized) conductarr.close() if (::conductarr.isInitialized) conductarr.close()
} }
@ -67,51 +68,59 @@ 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")
private fun verifyHomeViewport(reduced: Boolean) { private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin") {
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 snapshot = HomeFixture(rule.activity).snapshot(viewer.key)
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 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) val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) {
val onBack = rememberWolfskinBackHandler(shell, navigation) WolfskinApp(server, user, navigation, preferences, viewModel = app,
NavDisplay(backStack = navigation.backStack, onBack = onBack, homeContent = { shell, homeServer, homeUser, modifier ->
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), val source = androidx.lifecycle.viewmodel.compose.viewModel {
entryProvider = { destination -> NavEntry(destination) { WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
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)
} }
} }) homeModel = source
} } } HomeDestination(shell, homeServer, homeUser, modifier, source)
},
titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = 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) }
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 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().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 +128,24 @@ 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
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

@ -77,6 +77,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 +229,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 +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 { 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 +278,14 @@ 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
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 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. */ /** 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) 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 +365,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,18 @@ 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 and require-login selection rules are unchanged.
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;
an unfinished switch/replacement falls back to the setup picker with a message,
without choosing a different authenticated person.
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).

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