Preserve Home Back viewport and recover offline person switches #84
12 changed files with 578 additions and 83 deletions
|
|
@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
|
@ -51,6 +53,7 @@ class ServerRepository
|
||||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
private val mostRecentServerProvider: MostRecentServerProvider,
|
private val mostRecentServerProvider: MostRecentServerProvider,
|
||||||
) {
|
) {
|
||||||
|
private val userChangeMutex = Mutex()
|
||||||
private var _current = MutableStateFlow<CurrentUser?>(null)
|
private var _current = MutableStateFlow<CurrentUser?>(null)
|
||||||
val current: StateFlow<CurrentUser?> = _current
|
val current: StateFlow<CurrentUser?> = _current
|
||||||
|
|
||||||
|
|
@ -100,25 +103,36 @@ class ServerRepository
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
): CurrentUser =
|
): CurrentUser =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
if (server.id != user.serverId) {
|
userChangeMutex.withLock {
|
||||||
throw IllegalStateException("User is not part of the server")
|
if (server.id != user.serverId) {
|
||||||
}
|
throw IllegalStateException("User is not part of the server")
|
||||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
}
|
||||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||||
val (updatedServer, userDto) = readSession(server)
|
val previous = _current.value
|
||||||
var updatedUser =
|
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
||||||
user.copy(
|
try {
|
||||||
id = userDto.id,
|
val (updatedServer, userDto) = readSession(server)
|
||||||
name = userDto.name,
|
var updatedUser =
|
||||||
)
|
user.copy(
|
||||||
serverDao.addOrUpdateServer(updatedServer)
|
id = userDto.id,
|
||||||
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
name = userDto.name,
|
||||||
|
)
|
||||||
|
serverDao.addOrUpdateServer(updatedServer)
|
||||||
|
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
||||||
|
|
||||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||||
mostRecentServerProvider.save(currentUser)
|
currentCoroutineContext().ensureActive()
|
||||||
_current.value = currentUser
|
mostRecentServerProvider.save(currentUser)
|
||||||
_currentUserDto.value = ServerUserConfig(userDto)
|
currentCoroutineContext().ensureActive()
|
||||||
return@withContext currentUser
|
_current.value = currentUser
|
||||||
|
_currentUserDto.value = ServerUserConfig(userDto)
|
||||||
|
currentUser
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
// A failed or cancelled selection must not lend its token to the still-signed-in person.
|
||||||
|
apiClient.update(baseUrl = previous?.server?.url, accessToken = previous?.user?.accessToken)
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
|
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ import com.github.damontecres.wholphin.ui.successResponse
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -106,6 +111,64 @@ class ServerSavedSessionTest {
|
||||||
assertNull(repository.currentUserDto)
|
assertNull(repository.currentUserDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun failedPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) {
|
||||||
|
verifyFailedSwitch(cancel = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancelledPersonSwitchPreservesTheAuthenticatedTokenAndSavedIdentity() = runTest(dispatcher) {
|
||||||
|
verifyFailedSwitch(cancel = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancelledOlderSwitchCannotReplaceTheNewerAuthenticatedToken() = runTest(dispatcher) {
|
||||||
|
var activeToken: String? = null
|
||||||
|
every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() }
|
||||||
|
repository.restoreSavedSession()
|
||||||
|
val older = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Older selection", accessToken = "older-token"))
|
||||||
|
val newer = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Newer selection", accessToken = "newer-token"))
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
coEvery { recent.save(any()) } just Runs
|
||||||
|
coEvery { users.getCurrentUser() } coAnswers {
|
||||||
|
if (activeToken == older.accessToken) {
|
||||||
|
entered.complete(Unit)
|
||||||
|
withContext(NonCancellable) { release.await() }
|
||||||
|
throw IOException("late failed selection")
|
||||||
|
}
|
||||||
|
successResponse(dto.copy(id = newer.id, name = newer.name))
|
||||||
|
}
|
||||||
|
val pending = async { runCatching { repository.changeUser(server, older) } }
|
||||||
|
entered.await()
|
||||||
|
pending.cancel()
|
||||||
|
val selected = async { repository.changeUser(server, newer) }
|
||||||
|
runCurrent()
|
||||||
|
release.complete(Unit)
|
||||||
|
pending.join()
|
||||||
|
val current = selected.await()
|
||||||
|
assertEquals(newer.id, current.user.id)
|
||||||
|
assertEquals(current, repository.current.value)
|
||||||
|
assertEquals(newer.accessToken, activeToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun verifyFailedSwitch(cancel: Boolean) {
|
||||||
|
var activeToken: String? = null
|
||||||
|
every { api.update(any(), any(), any(), any()) } answers { activeToken = secondArg() }
|
||||||
|
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
||||||
|
val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person", accessToken = "other-token"))
|
||||||
|
coEvery { users.getCurrentUser() } coAnswers {
|
||||||
|
if (cancel) awaitCancellation() else throw IOException("offline")
|
||||||
|
}
|
||||||
|
if (cancel) withTimeoutOrNull(1_000) { repository.changeUser(server, selected) }
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
repository.changeUser(server, selected)
|
||||||
|
fail("Offline selection must fail")
|
||||||
|
} catch (_: IOException) { }
|
||||||
|
}
|
||||||
|
assertEquals(first, repository.current.value)
|
||||||
|
assertEquals(user.accessToken, activeToken)
|
||||||
|
coVerify(exactly = 0) { recent.save(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
|
@Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) {
|
||||||
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser
|
||||||
val entered = CompletableDeferred<Unit>()
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.*
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.*
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** A failed person switch must leave an escapable picker without changing the authenticated person. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class OfflinePersonSwitchTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val fake = FakeConductarr()
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val server = JellyfinServer(UUID.randomUUID(), "Household", fake.url, null)
|
||||||
|
private val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN).map { (name, token) ->
|
||||||
|
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
|
||||||
|
}
|
||||||
|
private val original = CurrentUser(server, users.first())
|
||||||
|
private val current = MutableStateFlow<CurrentUser?>(original)
|
||||||
|
private val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.AppContent(original))
|
||||||
|
private val session = WolfskinSession()
|
||||||
|
private val setupModel = WolfskinSetupViewModel(repository, navigation, setup, session, conductarr)
|
||||||
|
private lateinit var model: WolfskinShellViewModel
|
||||||
|
private var oldFixtures = false
|
||||||
|
private var oldScreens = true
|
||||||
|
private var oldUrl = ""
|
||||||
|
|
||||||
|
@Before fun configure() {
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
oldFixtures = ConductarrPreference.fixtures
|
||||||
|
oldScreens = WolfskinScreenPreference.enabled
|
||||||
|
oldUrl = ConductarrPreference.baseUrl
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
every { repository.current } returns current
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||||
|
coEvery { repository.changeUser(server, users[1]) } throws IOException("offline")
|
||||||
|
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
|
||||||
|
val backdrop = mockk<BackdropService>(relaxed = true)
|
||||||
|
every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE)
|
||||||
|
model = WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, backdrop,
|
||||||
|
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
|
||||||
|
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true))
|
||||||
|
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
||||||
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
|
rule.setContent {
|
||||||
|
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), backdrop,
|
||||||
|
screensaver, current, mockk(relaxed = true), setupInterceptor = { destination, modifier ->
|
||||||
|
if (destination is SetupDestination.AppContent) {
|
||||||
|
WolfskinApp(server, destination.current.user, navigation,
|
||||||
|
UserPreferences(AppPreferences.getDefaultInstance(), null), modifier, model,
|
||||||
|
homeContent = { shell, _, user, contentModifier ->
|
||||||
|
val home = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) }
|
||||||
|
HomeScreen(shell, home, contentModifier) {}
|
||||||
|
})
|
||||||
|
true
|
||||||
|
} else WolfskinSetupContent(destination, modifier, setupModel)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await { session.state?.people?.size == 2 && model.signedInPerson == "justin" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun close() {
|
||||||
|
if (::model.isInitialized) model.viewModelScope.cancel()
|
||||||
|
setupModel.viewModelScope.cancel()
|
||||||
|
conductarr.close()
|
||||||
|
fake.close()
|
||||||
|
ConductarrPreference.updateFixtures(oldFixtures)
|
||||||
|
ConductarrPreference.updateBaseUrl(oldUrl)
|
||||||
|
WolfskinScreenPreference.enabled = oldScreens
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedAvatarSwitchBackDismissesToOriginalNavThenExits() {
|
||||||
|
failSwitch(fromAvatar = true)
|
||||||
|
back()
|
||||||
|
assertFalse("Dismissing a reopened picker must not exit yet", rule.activity.isFinishing)
|
||||||
|
assertEquals("Back must dismiss to the still-authenticated app", SetupDestination.AppContent(original), stack.single())
|
||||||
|
assertFalse("Back cannot remain trapped on Who's watching", session.state!!.pickerVisible)
|
||||||
|
assertEquals(users.first().id.toString(), session.state!!.person.jellyfinUserId)
|
||||||
|
assertTrue(session.state!!.navFocused)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
back()
|
||||||
|
assertTrue("The following Back from nav must exit", rule.activity.isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedAvatarSwitchExplainsThatTheServerDidNotAnswer() {
|
||||||
|
failSwitch(fromAvatar = true)
|
||||||
|
rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed()
|
||||||
|
rule.onRoot().captureRoboImage(File(System.getProperty("wolfskin.captureDirectory"), "offline-switch-sdk${android.os.Build.VERSION.SDK_INT}.png"))
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedAvatarSwitchRestoresOnlyTheOriginalHouseholdSession() {
|
||||||
|
failSwitch(fromAvatar = true)
|
||||||
|
await { conductarr.identity.value?.signedIn == true }
|
||||||
|
val identity = requireNotNull(conductarr.identity.value)
|
||||||
|
assertEquals("${server.id}:${users.first().id}", identity.person)
|
||||||
|
assertEquals("justin", identity.operator!!.requester)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun expiredTargetCredentialsStillOpenTheSignInGate() {
|
||||||
|
coEvery { repository.changeUser(server, users[1]) } throws org.jellyfin.sdk.api.client.exception.InvalidStatusException(401)
|
||||||
|
failSwitch(fromAvatar = true)
|
||||||
|
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
|
||||||
|
rule.onNodeWithText("Mindel").performClick()
|
||||||
|
await { setupModel.stage == WolfskinSetupViewModel.Stage.Login }
|
||||||
|
rule.onNodeWithTag("profile-password").assertIsDisplayed()
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedLaunchPickerSelectionBackExitsWithoutChangingIdentity() {
|
||||||
|
failSwitch(fromAvatar = false)
|
||||||
|
back()
|
||||||
|
assertTrue("A launch picker must exit rather than expose the old person's Home", rule.activity.isFinishing)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun coldSetupPickerBackExitsWithoutRestoringTheSignedInEntry() {
|
||||||
|
rule.runOnIdle { stack[0] = SetupDestination.UserList(server) }
|
||||||
|
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
|
||||||
|
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||||
|
back()
|
||||||
|
assertTrue("Cold setup picker Back must exit, even with an old identity in memory", rule.activity.isFinishing)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedSetupSelectionEscapeStillExitsThePicker() {
|
||||||
|
val response = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, users[1]) } coAnswers {
|
||||||
|
response.await()
|
||||||
|
throw IOException("offline")
|
||||||
|
}
|
||||||
|
rule.runOnIdle { stack[0] = SetupDestination.UserList(server) }
|
||||||
|
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
|
||||||
|
rule.mainClock.advanceTimeBy(1_000)
|
||||||
|
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionRight) }
|
||||||
|
assertEquals(1, setupModel.picker!!.pickerIndex)
|
||||||
|
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
response.complete(Unit)
|
||||||
|
await { setupModel.stage == WolfskinSetupViewModel.Stage.Unavailable }
|
||||||
|
back()
|
||||||
|
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
|
||||||
|
rule.onNodeWithTag("wolfskin-setup-picker").performKeyInput { pressKey(Key.Escape) }
|
||||||
|
assertTrue("Escape and system Back must share the setup picker's exit", rule.activity.isFinishing)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unansweredSwitchCannotKeepInputCoveredIndefinitely() {
|
||||||
|
var entered = false
|
||||||
|
coEvery { repository.changeUser(server, users[1]) } coAnswers {
|
||||||
|
entered = true
|
||||||
|
awaitCancellation()
|
||||||
|
}
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter))
|
||||||
|
await { entered }
|
||||||
|
rule.runOnIdle { Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(15)) }
|
||||||
|
rule.mainClock.advanceTimeBy(11_000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertNull("An unanswered switch must release its input cover within 15 seconds", session.personCommit)
|
||||||
|
await { rule.onAllNodesWithTag("wolfskin-setup-picker").fetchSemanticsNodes().isNotEmpty() }
|
||||||
|
rule.onNodeWithText("The household server didn’t answer", substring = true).assertIsDisplayed()
|
||||||
|
back()
|
||||||
|
assertTrue(rule.activity.isFinishing)
|
||||||
|
assertEquals(original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failSwitch(fromAvatar: Boolean) {
|
||||||
|
if (fromAvatar) {
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
await { session.personCommit == null && session.state?.pickerVisible == false }
|
||||||
|
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
|
||||||
|
}
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter))
|
||||||
|
await { session.personCommit == null && (session.state?.pickerVisible == true || setupModel.stage == WolfskinSetupViewModel.Stage.Picker) }
|
||||||
|
assertEquals("A failed switch cannot select another authenticated person", original, current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(keys: List<Key>) {
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun back() {
|
||||||
|
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
rule.mainClock.advanceTimeBy(32)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun await(condition: () -> Boolean) {
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
condition()
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.BackEventCompat
|
import androidx.activity.BackEventCompat
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
|
|
@ -21,15 +21,28 @@ import java.io.File
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import com.github.damontecres.wholphin.MainContent
|
||||||
|
import com.github.damontecres.wholphin.SignedInContent
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropResult
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -46,18 +59,32 @@ import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */
|
/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
class TitleEntryBackTest {
|
class TitleEntryBackTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
private var systemAnimationScale = 1f
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(
|
||||||
|
effectContext = object : androidx.compose.ui.MotionDurationScale {
|
||||||
|
override val scaleFactor get() = systemAnimationScale
|
||||||
|
},
|
||||||
|
)
|
||||||
private lateinit var viewModel: WolfskinTitleViewModel
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
private lateinit var conductarr: ConductarrSession
|
private lateinit var conductarr: ConductarrSession
|
||||||
private var homeModel: WolfskinHomeViewModel? = null
|
private var homeModel: WolfskinHomeViewModel? = null
|
||||||
|
private var shellModel: WolfskinShellViewModel? = null
|
||||||
private var previousFixtures: Boolean? = null
|
private var previousFixtures: Boolean? = null
|
||||||
|
private var previousHomeIo: CoroutineDispatcher? = null
|
||||||
|
private var previousLeanback: Boolean? = null
|
||||||
|
|
||||||
@After fun dispose() {
|
@After fun dispose() {
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||||
homeModel?.viewModelScope?.cancel()
|
homeModel?.viewModelScope?.cancel()
|
||||||
|
shellModel?.viewModelScope?.cancel()
|
||||||
|
previousHomeIo?.let { WholphinDispatchers.IO = it }
|
||||||
|
previousHomeIo = null
|
||||||
|
previousLeanback?.let { org.robolectric.Shadows.shadowOf(rule.activity.packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, it) }
|
||||||
previousFixtures?.let(ConductarrPreference::updateFixtures)
|
previousFixtures?.let(ConductarrPreference::updateFixtures)
|
||||||
if (::conductarr.isInitialized) conductarr.close()
|
if (::conductarr.isInitialized) conductarr.close()
|
||||||
}
|
}
|
||||||
|
|
@ -67,51 +94,104 @@ class TitleEntryBackTest {
|
||||||
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
|
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
|
||||||
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
|
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
|
||||||
|
|
||||||
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false)
|
@Test fun homeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1)
|
||||||
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true)
|
@Test fun reducedMotionHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = true, depth = 1)
|
||||||
|
@Test fun deepHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 8)
|
||||||
|
@Test fun sharedHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared")
|
||||||
|
@Test fun loadingHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", paintLoading = true)
|
||||||
|
@Test fun systemAnimationsOffHomeTitleBackKeepsTheExactPreOpenViewport() = verifyHomeViewport(reduced = false, depth = 1, person = "shared", systemScale = 0f)
|
||||||
|
|
||||||
private fun verifyHomeViewport(reduced: Boolean) {
|
private fun verifyHomeViewport(reduced: Boolean, depth: Int, person: String = "justin", paintLoading: Boolean = false, systemScale: Float = 1f) {
|
||||||
|
// A television qualifier alone does not advertise Leanback or leave Robolectric's touch mode.
|
||||||
|
systemAnimationScale = systemScale
|
||||||
|
val packageManager = rule.activity.packageManager
|
||||||
|
previousLeanback = packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK)
|
||||||
|
org.robolectric.Shadows.shadowOf(packageManager).setSystemFeature(android.content.pm.PackageManager.FEATURE_LEANBACK, true)
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", true)
|
val session = WolfskinSession()
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false }
|
val heldReads = if (paintLoading) StandardTestDispatcher().also {
|
||||||
val snapshot = HomeFixture(rule.activity).snapshot(viewer.key)
|
previousHomeIo = WholphinDispatchers.IO
|
||||||
|
WholphinDispatchers.IO = it
|
||||||
|
} else null
|
||||||
conductarr = ConductarrSession(MemoryCacheStore())
|
conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
previousFixtures = ConductarrPreference.fixtures
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
ConductarrPreference.updateFixtures(true)
|
ConductarrPreference.updateFixtures(true)
|
||||||
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
val server = com.github.damontecres.wholphin.data.model.JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
val user = com.github.damontecres.wholphin.data.model.JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = person.replaceFirstChar { it.uppercase() }, serverId = server.id, accessToken = null)
|
||||||
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, WolfskinSession())
|
|
||||||
homeModel = source
|
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
|
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
|
||||||
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||||
|
val backdrop = mockk<BackdropService>(relaxed = true)
|
||||||
|
every { backdrop.backdropFlow } returns MutableStateFlow(BackdropResult.NONE)
|
||||||
|
val app = WolfskinShellViewModel(rule.activity, session, mockk(relaxed = true), navigation, mockk(relaxed = true),
|
||||||
|
backdrop, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
|
||||||
|
org.justinvisser.wolfskin.tv.update.FakeUpdateController(), mockk(relaxed = true))
|
||||||
|
shellModel = app
|
||||||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
val current = CurrentUser(server, user)
|
||||||
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
val live = MutableStateFlow<CurrentUser?>(current)
|
||||||
NavDisplay(backStack = navigation.backStack, onBack = onBack,
|
val setup = androidx.compose.runtime.mutableStateListOf<SetupDestination>(SetupDestination.AppContent(current))
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
val trailer = mockk<TrailerReturnState>(relaxed = true)
|
||||||
entryProvider = { destination -> NavEntry(destination) {
|
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
||||||
val active = navigation.backStack.lastOrNull() == destination
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
|
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) {
|
||||||
LaunchedEffect(destination, active) { if (active) shell.enterDestination(route) }
|
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||||
ScreenEntry(shell, route, active) {
|
androidx.compose.runtime.LaunchedEffect(inputMode) { inputMode.requestInputMode(androidx.compose.ui.input.InputMode.Keyboard) }
|
||||||
if (destination is Destination.Home) ShellSurface(shell) { modifier ->
|
MainContent(setup, navigation, preferences, backdrop, screensaver, live, trailer,
|
||||||
HomeDestination(shell, server, user, modifier, source)
|
modifier = Modifier.fillMaxSize(),
|
||||||
} else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = true)
|
setupInterceptor = { destination, modifier ->
|
||||||
|
val entry = (destination as SetupDestination.AppContent).current
|
||||||
|
SignedInContent(entry, live, preferences.appPreferences.signInAutomatically, trailer,
|
||||||
|
{ navigation.backStack.lastOrNull() }) {
|
||||||
|
WolfskinApp(entry.server, entry.user, navigation, preferences, modifier, app,
|
||||||
|
homeContent = { shell, homeServer, homeUser, contentModifier ->
|
||||||
|
val source = androidx.lifecycle.viewmodel.compose.viewModel {
|
||||||
|
WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
|
||||||
|
}
|
||||||
|
homeModel = source
|
||||||
|
HomeDestination(shell, homeServer, homeUser, contentModifier, source)
|
||||||
|
},
|
||||||
|
titleContent = { route, shell, settings -> TitleDestination(route, shell, settings, viewModel = viewModel, fixtures = true) })
|
||||||
}
|
}
|
||||||
} })
|
true
|
||||||
} } }
|
})
|
||||||
|
} }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
|
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
source.snapshot.rows.any { it.id == "cw" } && !shell.navFocused
|
session.state != null && app.signedInPerson == person
|
||||||
}
|
}
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
if (heldReads != null) {
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
homeModel != null && session.personCommit == null
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("wolfskin-home").assertExists()
|
||||||
|
assertEquals(HomeAvailability.Loading, homeModel!!.snapshot.conductarr)
|
||||||
|
val loadingCaptures = File(System.getProperty("wolfskin.captureDirectory"), "home-loading-$person").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(loadingCaptures, "sdk${Build.VERSION.SDK_INT}.png").absolutePath)
|
||||||
|
heldReads.scheduler.runCurrent()
|
||||||
|
WholphinDispatchers.IO = previousHomeIo!!
|
||||||
|
previousHomeIo = null
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
homeModel?.snapshot?.rows?.any { it.id == "cw" } == true && session.personCommit == null
|
||||||
|
}
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
val snapshot = requireNotNull(homeModel).snapshot
|
||||||
|
rule.onRoot().performKeyInput { repeat(depth) { pressKey(Key.DirectionDown) } }
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
val home = shell.rememberedView("home")
|
val home = shell.rememberedView("home")
|
||||||
val tag = "home-card:${snapshot.rows[home.row].id}:0"
|
val tag = "home-card:${snapshot.rows[home.row].id}:0"
|
||||||
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
val before = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
||||||
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-${if (reduced) "reduced" else "animated"}").apply { mkdirs() }
|
val motion = if (systemScale == 0f) "system-off" else if (reduced) "reduced" else "animated"
|
||||||
|
val captures = File(System.getProperty("wolfskin.captureDirectory"), "home-title-back-$person-$motion-$depth${if (paintLoading) "-loading" else ""}").apply { mkdirs() }
|
||||||
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-before.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
rule.waitUntil(30_000) {
|
rule.waitUntil(30_000) {
|
||||||
|
|
@ -119,12 +199,32 @@ class TitleEntryBackTest {
|
||||||
viewModel.title?.data?.status == TitleReadStatus.Ready
|
viewModel.title?.data?.status == TitleReadStatus.Ready
|
||||||
}
|
}
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
dispatchBack(predictive = false)
|
rule.mainClock.autoAdvance = false
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
rule.runOnIdle {
|
||||||
|
rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
|
||||||
|
rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
|
||||||
|
}
|
||||||
|
if (navigation.backStack.size > 1) dispatchBack(predictive = false)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
rule.runOnIdle {
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(64)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-after.png").absolutePath)
|
||||||
assertEquals(before, home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset })
|
val after = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
||||||
assertEquals(bounds, rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot)
|
val afterBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(1000))
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-settled.png").absolutePath)
|
||||||
|
val settled = home.listState.let { it.firstVisibleItemIndex to it.firstVisibleItemScrollOffset }
|
||||||
|
val settledBounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertEquals("settled card bounds before=$bounds after=$settledBounds; list before=$before after=$settled", before, settled)
|
||||||
|
assertEquals(bounds, settledBounds)
|
||||||
|
assertEquals("card bounds before=$bounds after=$afterBounds; list before=$before after=$after", before, after)
|
||||||
|
assertEquals(bounds, afterBounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
|
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||||
import org.justinvisser.wolfskin.design.SettingsList
|
import org.justinvisser.wolfskin.design.SettingsList
|
||||||
|
|
@ -77,6 +78,7 @@ class WolfskinSession @Inject constructor() {
|
||||||
internal var fixtureMode: Boolean? = null
|
internal var fixtureMode: Boolean? = null
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
var onAction: (ShellAction) -> Unit = {}
|
||||||
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
|
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
|
||||||
|
var setupMessage: String? = null
|
||||||
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
|
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +230,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
ShellAction.Pop -> navigation.goBack()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
ShellAction.Exit -> navigation.requestExit()
|
ShellAction.Exit -> navigation.requestExit()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server)
|
is ShellAction.Person -> selectPerson(action, server, user)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||||
|
|
@ -238,7 +240,9 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
private fun selectPerson(selection: ShellAction.Person, server: JellyfinServer, user: JellyfinUser) {
|
||||||
|
val person = selection.person
|
||||||
|
val previousPerson = signedInPerson
|
||||||
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
|
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
|
||||||
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
|
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +279,17 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
|
val open = {
|
||||||
|
val current = repository.current.value
|
||||||
|
val isAuthenticationFailure = exception is InvalidStatusException && (exception.status == 401 || exception.status == 403)
|
||||||
|
if (!isAuthenticationFailure && current?.server?.id == server.id && current.user.id == user.id) {
|
||||||
|
signedInPerson = previousPerson
|
||||||
|
session.state?.restorePersonPicker(previousPerson, selection.fromLaunch)
|
||||||
|
session.state?.toast = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable
|
||||||
|
identityJob = viewModelScope.launch { retryConductarr(server, user) }
|
||||||
|
} else if (isAuthenticationFailure) setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
|
else openSwitchRecovery(server)
|
||||||
|
}
|
||||||
if (commit == null) open() else commit.fallback = open
|
if (commit == null) open() else commit.fallback = open
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -284,6 +298,18 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A stuck replacement cannot retain an inert picker or expose a possibly changed identity. */
|
||||||
|
fun expirePersonSwitch(server: JellyfinServer) {
|
||||||
|
personSwitchJob?.cancel()
|
||||||
|
session.personCommit = null
|
||||||
|
openSwitchRecovery(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSwitchRecovery(server: JellyfinServer) {
|
||||||
|
session.setupMessage = org.justinvisser.wolfskin.design.RecoveryMessage.PersonSwitchUnavailable
|
||||||
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
||||||
|
|
@ -293,6 +319,8 @@ fun handOffDestination(group: String): Destination = when (group) {
|
||||||
else -> Destination.Settings(PreferenceScreenOption.BASIC)
|
else -> Destination.Settings(PreferenceScreenOption.BASIC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val PERSON_SWITCH_TIMEOUT_MILLIS = 10_000L
|
||||||
|
|
||||||
private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") }
|
private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") }
|
||||||
|
|
||||||
/** The one upstream hook preserves destination decorators and full-screen hand-offs. */
|
/** The one upstream hook preserves destination decorators and full-screen hand-offs. */
|
||||||
|
|
@ -341,6 +369,12 @@ fun WolfskinApp(
|
||||||
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
val onBack = rememberWolfskinBackHandler(state, navigationManager)
|
||||||
val session = viewModel.session
|
val session = viewModel.session
|
||||||
val commit = session.personCommit
|
val commit = session.personCommit
|
||||||
|
LaunchedEffect(commit) {
|
||||||
|
if (commit != null) {
|
||||||
|
kotlinx.coroutines.delay(PERSON_SWITCH_TIMEOUT_MILLIS)
|
||||||
|
if (session.personCommit === commit) viewModel.expirePersonSwitch(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
|
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
|
||||||
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
|
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
|
||||||
session.personCommit = null
|
session.personCommit = null
|
||||||
|
|
|
||||||
|
|
@ -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,22 @@ require-login profiles still go through the existing protected sign-in flow.
|
||||||
cannot replace a different selected person. Name revalidation updates only the
|
cannot replace a different selected person. Name revalidation updates only the
|
||||||
name column and reloads the saved user, preserving newer settings and PINs.
|
name column and reloads the saved user, preserving newer settings and PINs.
|
||||||
|
|
||||||
|
A failed person switch reopens the picker on the still-authenticated person and
|
||||||
|
shows “The household server didn’t answer.” Back from a reopened avatar picker
|
||||||
|
dismisses to that person's navigation; the next Back exits. Back from a launch or
|
||||||
|
setup picker exits instead of exposing a previous person's content. Settings,
|
||||||
|
login and error panels return to the picker first; Back can exit a busy setup
|
||||||
|
request. PIN, require-login and rejected-credential authentication gates remain
|
||||||
|
strict: an expired target sign-in still reaches the setup login form.
|
||||||
|
|
||||||
|
Failed or cancelled Jellyfin switches restore the previous API token as well as
|
||||||
|
retaining the signed-in identity. Recovering the picker also reattaches only that
|
||||||
|
person's household client. A person-switch cover has a ten-second deadline;
|
||||||
|
an unfinished switch/replacement falls back to the setup picker with a message,
|
||||||
|
without choosing a different authenticated person. Repository user changes share
|
||||||
|
one transaction gate, so late cancellation/rollback cannot overwrite a newer
|
||||||
|
person's authenticated token.
|
||||||
|
|
||||||
Jellyfin, Conductarr, artwork, playback and crash-report clients share one
|
Jellyfin, Conductarr, artwork, playback and crash-report clients share one
|
||||||
`PersistentDns` instance through the application graph. It keeps system-ordered
|
`PersistentDns` instance through the application graph. It keeps system-ordered
|
||||||
IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts).
|
IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts).
|
||||||
|
|
@ -637,6 +653,10 @@ shell's logical focus without requests against uncomposed lazy items.
|
||||||
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
||||||
a manually scrolled saved view. The next arrow re-enters visible content when
|
a manually scrolled saved view. The next arrow re-enters visible content when
|
||||||
scrolling moved focus off-screen.
|
scrolling moved focus off-screen.
|
||||||
|
Shell-owned Home, Library/See all, Discover and Settings/Updates lists use
|
||||||
|
Foundation's minimum-visibility bring-into-view policy, not TV's 30% focus pivot.
|
||||||
|
Restoring focus therefore leaves already-visible cards at their saved position;
|
||||||
|
native title/person and legacy handoffs retain their inherited policy.
|
||||||
Pointing changes focus without activation, cards activate on key-up, and held
|
Pointing changes focus without activation, cards activate on key-up, and held
|
||||||
OK events cannot activate twice.
|
OK events cannot activate twice.
|
||||||
Between Home shelves, an unvisited row starts at its leftmost card. A visited row
|
Between Home shelves, an unvisited row starts at its leftmost card. A visited row
|
||||||
|
|
@ -959,12 +979,15 @@ retain the approved frames except for the requested feedback changes:
|
||||||
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
||||||
|
|
||||||
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
||||||
directions, first focus and exact scroll/card bounds after native Back from the
|
directions, first focus and exact scroll/card bounds after returning from the
|
||||||
actual title surface. `TitleEntryBackTest` also checks the production Home/title
|
actual title surface. `TitleEntryBackTest` also checks production MainContent,
|
||||||
loaders through `NavDisplay` and its entry decorators on SDK 34 and 36, with
|
the Home/title loaders, `NavDisplay` decorators and native system Back on SDK 34
|
||||||
animation and reduced motion. Native proof records real Bear season-three
|
and 36. It models Leanback capabilities and keyboard input, animation/reduced
|
||||||
metadata through the title read facade; the proof-only harness is not part of
|
motion, initial Home loading and system animation scale zero. Phone/touch-mode
|
||||||
the shipped tests or APK.
|
tests miss the TV focus pivot; the zero-scale regression reproduced the exact
|
||||||
|
72-pixel drift before the shared policy fix. Native proof records real Bear
|
||||||
|
season-three metadata through the title read facade; the proof-only harness is
|
||||||
|
not part of the shipped tests or APK.
|
||||||
|
|
||||||
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
||||||
then hydrates the selected playback target through the existing single-item read,
|
then hydrates the selected playback target through the existing single-item read,
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
|
@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -32,6 +36,9 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
private object MinimumScrollBringIntoViewSpec : BringIntoViewSpec
|
||||||
|
|
||||||
/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */
|
/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */
|
||||||
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
||||||
Key.DirectionUp -> ShellKey.Up
|
Key.DirectionUp -> ShellKey.Up
|
||||||
|
|
@ -51,6 +58,7 @@ fun ShellState.handleKey(event: KeyEvent): Boolean {
|
||||||
|
|
||||||
/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
|
/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun ShellSurface(
|
fun ShellSurface(
|
||||||
state: ShellState,
|
state: ShellState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -96,8 +104,13 @@ fun ShellSurface(
|
||||||
if (state.pickerVisible) {
|
if (state.pickerVisible) {
|
||||||
WhosWatching(state)
|
WhosWatching(state)
|
||||||
} else {
|
} else {
|
||||||
FocusRingHost {
|
// Logical-focus screens own their scroll position; TV's 30% focus pivot must not recenter it.
|
||||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides if (handlesContentKeys) MinimumScrollBringIntoViewSpec else LocalBringIntoViewSpec.current,
|
||||||
|
) {
|
||||||
|
FocusRingHost {
|
||||||
|
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||||
ComingInSheet(state)
|
ComingInSheet(state)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue