Preserve Home Back viewport and recover offline person switches #84
11 changed files with 400 additions and 68 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`PersistentDns` instance through the application graph. It keeps system-ordered
|
||||
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 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 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. */
|
||||
sealed interface 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 object Pop : ShellAction
|
||||
data object Exit : ShellAction
|
||||
|
|
@ -212,6 +212,7 @@ class ShellState(
|
|||
}
|
||||
|
||||
private fun choosePerson(selected: ShellPerson) {
|
||||
val fromLaunch = pickerLaunch
|
||||
person = selected
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
|
|
@ -223,7 +224,18 @@ class ShellState(
|
|||
requestsVisible = false
|
||||
preparePersonMemory()
|
||||
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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue