Recover failed person switches without changing identity
This commit is contained in:
parent
c21d17dfdf
commit
9355288f79
11 changed files with 400 additions and 68 deletions
|
|
@ -104,21 +104,30 @@ 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)
|
||||||
val (updatedServer, userDto) = readSession(server)
|
try {
|
||||||
var updatedUser =
|
val (updatedServer, userDto) = readSession(server)
|
||||||
user.copy(
|
var updatedUser =
|
||||||
id = userDto.id,
|
user.copy(
|
||||||
name = userDto.name,
|
id = userDto.id,
|
||||||
)
|
name = userDto.name,
|
||||||
serverDao.addOrUpdateServer(updatedServer)
|
)
|
||||||
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
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)
|
||||||
|
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. */
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue