diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 6b3403c6..4f2a1364 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -33,6 +33,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.RestoredSession +import org.justinvisser.wolfskin.tv.recoverWolfskinSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -479,11 +480,13 @@ class MainActivityViewModel fun appResume() { viewModelScope.launchDefault { mutex.withLock { + val expected = serverRepository.current.value try { Timber.v("Updating userDto") serverRepository.updateUserDto() } catch (ex: Exception) { Timber.w(ex, "Error during appResume") + recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager) } } } @@ -590,6 +593,7 @@ class MainActivityViewModel } catch (exception: Exception) { if (exception is kotlinx.coroutines.CancellationException) throw exception Timber.w(exception, "Saved launch session revalidation failed") + recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index fcaa3165..d2b7c41e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver import org.justinvisser.wolfskin.tv.WolfskinApp +import org.justinvisser.wolfskin.tv.WolfskinSetupContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization @@ -48,6 +49,7 @@ fun MainContent( backdropService: BackdropService, screensaverService: ScreensaverService, modifier: Modifier = Modifier, + setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, ) { val preferences by rememberUpdatedState(userPreferences) Surface( @@ -72,6 +74,7 @@ fun MainContent( entryProvider = { key -> key as SetupDestination NavEntry(key) { + if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry when (key) { SetupDestination.Loading -> { Box( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt new file mode 100644 index 00000000..4c7adc37 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Intent +import android.os.Looper +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.Snapshot +import org.robolectric.Shadows +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.datastore.core.DataStore +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import com.github.damontecres.wholphin.MainActivityViewModel +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +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.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.coVerify +import java.io.File +import java.io.IOException +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +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.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class WolfskinSetupNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token") + private val repository = mockk(relaxed = true) + private val navigation = NavigationManager() + private val setup = mockk(relaxed = true) + private val stack = mutableStateListOf(SetupDestination.Loading) + private val conductarr = ConductarrSession(MemoryCacheStore()) + private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr) + private val preferences = mockk>() + private val intentService = mockk() + private var previousScreens = true + private var previousFixtures = false + + @Before fun configure() { + WolfskinScreenPreference.initialize(rule.activity) + previousScreens = WolfskinScreenPreference.enabled + WolfskinScreenPreference.enabled = true + ConductarrPreference.initialize(rule.activity) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + every { repository.current } returns MutableStateFlow(null) + coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server) + every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user))) + every { setup.navigateTo(any()) } answers { stack[0] = firstArg() } + every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance()) + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @After fun close() { + conductarr.close() + WolfskinScreenPreference.enabled = previousScreens + ConductarrPreference.updateFixtures(previousFixtures) + } + + private fun mount() { + val screensaver = mockk(relaxed = true) + every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { + MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + mockk(relaxed = true), screensaver, Modifier, + setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) + } + } + + private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService) + + private fun awaitStage(stage: WolfskinSetupViewModel.Stage) { + rule.waitUntil(5_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + model.stage == stage + } + } + + private fun assertOwnPicker() { + awaitStage(WolfskinSetupViewModel.Stage.Picker) + rule.onNodeWithText("Who’s watching?").assertIsDisplayed() + rule.onNodeWithText("Justin").assertIsDisplayed() + rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist() + rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist() + } + + @Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() { + runBlocking { appModel().handleAppStart() } + mount() + assertOwnPicker() + // The legacy sign-out-equivalent erased the recent preference, but Room still has the household. + coEvery { repository.getMostRecentServer() } returns RestoredSession.None + rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) } + assertOwnPicker() + rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) } + assertOwnPicker() + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in") + rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } } + rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList } + assertOwnPicker() + coVerify(exactly = 0) { repository.switchServerOrUser() } + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun unreachableSelectionRendersWolfskinRecoveryFrame() { + stack[0] = SetupDestination.UserList(server) + coEvery { repository.changeUser(server, user) } throws IOException("offline") + mount() + assertOwnPicker() + rule.onNodeWithText("Justin").performClick() + awaitStage(WolfskinSetupViewModel.Stage.Unavailable) + rule.onNodeWithText("Try again").assertIsDisplayed() + rule.onNodeWithText("Sign in").assertIsDisplayed() + rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png")) + } + + @Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() { + coEvery { repository.getMostRecentServer() } returns RestoredSession.None + every { repository.serverDao.getServers() } returns emptyList() + stack[0] = SetupDestination.ServerList + mount() + awaitStage(WolfskinSetupViewModel.Stage.Unconfigured) + rule.onNodeWithText("Connect your household server").assertIsDisplayed() + rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test") + rule.onNodeWithText("Try again").assertIsEnabled() + rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt new file mode 100644 index 00000000..879404b4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.pm.ActivityInfo +import android.os.Looper +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +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.test.TestActivity +import com.github.damontecres.wholphin.ui.TestModule +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class WolfskinSetupRollbackTest { + @get:Rule(order = 0) val hilt = HiltAndroidRule(this) + @get:Rule(order = 1) val registerActivity = object : TestWatcher() { + override fun starting(description: Description?) { + val context = ApplicationProvider.getApplicationContext() + shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply { + name = TestActivity::class.java.name + packageName = context.packageName + }) + } + } + @get:Rule(order = 2) val rule = createAndroidComposeRule() + @Inject lateinit var api: ApiClient + @Inject lateinit var jellyfin: Jellyfin + private var previousScreens = true + + @Before fun setup() { + hilt.inject() + WholphinDispatchers.configure(TestModule.testDispatcher) + WolfskinScreenPreference.initialize(rule.activity) + previousScreens = WolfskinScreenPreference.enabled + WolfskinScreenPreference.enabled = false + val users = mockk() + val system = mockk() + val quickConnect = mockk() + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { api.userApi } returns users + every { api.systemApi } returns system + every { api.quickConnectApi } returns quickConnect + coEvery { users.getPublicUsers() } returns successResponse(emptyList()) + coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11")) + coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false) + } + + @After fun restore() { + WolfskinScreenPreference.enabled = previousScreens + WholphinDispatchers.reset() + } + + @Test fun rollbackRendersBothStockSelectors() { + val stack = mutableStateListOf(SetupDestination.ServerList) + val screensaver = mockk() + every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { + MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), + mockk(relaxed = true), screensaver) + } + fun awaitHeading(resource: Int) { + rule.waitUntil(5_000) { + TestModule.testDispatcher.scheduler.advanceUntilIdle() + shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty() + } + rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed() + rule.onNodeWithText("Who’s watching?").assertDoesNotExist() + } + awaitHeading(R.string.select_server) + rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) } + awaitHeading(R.string.select_user) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt new file mode 100644 index 00000000..0c1515cc --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupTest.kt @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.MostRecentServer +import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.TestModule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.IOException +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class WolfskinSetupTest { + private val context get() = ApplicationProvider.getApplicationContext() + private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() } + private val recent by lazy { MostRecentServerSharedPreferences(context) } + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token") + private val api = mockk(relaxed = true) + private val usersApi = mockk() + private val systemApi = mockk() + private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) } + private val navigation = NavigationManager() + private val setup = mockk(relaxed = true) + private val session = WolfskinSession() + private val conductarr = ConductarrSession(MemoryCacheStore()) + private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) } + private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) } + private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false) + private var previousFixtures = false + + @Before fun saveHousehold(): Unit = runBlocking { + ConductarrPreference.initialize(context) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + recent.clear() + database.serverDao().addOrUpdateServer(server) + user = database.serverDao().addOrUpdateUser(user) + recent.save(CurrentUser(server, user)) + val client = jellyfin + every { api.userApi } returns usersApi + every { api.systemApi } returns systemApi + every { client.createApi(any(), any(), any(), any(), any()) } returns api + coEvery { usersApi.getCurrentUser() } returns successResponse(dto) + coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name)) + } + + @After fun close() { + conductarr.close() + database.close() + ConductarrPreference.updateFixtures(previousFixtures) + } + + @Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking { + val saved = recent.get() + model.load(null) + assertEquals(Stage.Picker, model.stage) + assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId) + assertEquals(saved, recent.get()) + recent.clear() + model.load(null) + assertEquals(server, model.server) + assertEquals(Stage.Picker, model.stage) + assertEquals(MostRecentServer.None, recent.get()) + } + + @Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking { + model.load(server) + coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401) + model.select(model.picker!!.person) + assertEquals(Stage.Login, model.stage) + assertEquals(user, model.loginUser) + assertNull(repository.current.value) + assertTrue(recent.get() is MostRecentServer.ServerAndUser) + coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token")) + coEvery { usersApi.getCurrentUser() } returns successResponse(dto) + model.login(user.name!!, "test-password") + assertEquals("renewed-test-token", repository.current.value!!.user.accessToken) + assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId) + assertFalse(session.state!!.pickerVisible) + assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId) + } + + @Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest { + model.load(server) + val saved = recent.get() + coEvery { usersApi.getCurrentUser() } throws IOException("offline") + model.select(model.picker!!.person) + assertEquals(Stage.Unavailable, model.stage) + assertEquals(saved, recent.get()) + val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() } + assertEquals(Stage.Loading, model.stage) + retry.await() + assertEquals(Stage.Picker, model.stage) + coEvery { usersApi.getCurrentUser() } returns successResponse(dto) + model.select(model.picker!!.person) + assertEquals(user.id, repository.current.value!!.user.id) + assertFalse(session.state!!.pickerVisible) + } + + @Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest { + database.serverDao().deleteUser(server.id, user.id) + val saved = recent.get() + coEvery { usersApi.getPublicUsers() } throws IOException("offline") + model.load(null) + assertEquals(Stage.Unavailable, model.stage) + val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() } + assertEquals(Stage.Loading, model.stage) + retry.await() + assertEquals(Stage.Unavailable, model.stage) + assertEquals(server, model.server) + assertEquals(saved, recent.get()) + } + + @Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking { + user = user.copy(pin = "URDL") + database.serverDao().updateUser(user) + model.load(server) + model.select(model.picker!!.person) + assertEquals(user, model.pinUser) + assertTrue(model.picker!!.pickerVisible) + model.enterPin("WRONG") + assertNull(repository.current.value) + model.enterPin("URDL") + assertEquals(user.id, repository.current.value!!.user.id) + user = user.copy(pin = null, requireLogin = true) + database.serverDao().updateUser(user) + model.load(server) + model.select(model.picker!!.person) + assertEquals(Stage.Login, model.stage) + assertEquals(user, model.loginUser) + } + + @Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking { + database.serverDao().deleteUser(server.id, user.id) + coEvery { usersApi.getPublicUsers() } throws IOException("offline") + model.load(null) + assertEquals(Stage.Unavailable, model.stage) + assertEquals(server, model.server) + model.showLogin(null) + coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult( + user = dto, serverId = server.id.toString(), accessToken = "new-test-token")) + model.login(user.name!!, "test-password") + assertEquals(user.id, repository.current.value!!.user.id) + } + + @Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking { + WolfskinScreenPreference.initialize(context) + val previous = WolfskinScreenPreference.enabled + try { + WolfskinScreenPreference.enabled = true + val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser + var destination: SetupDestination = SetupDestination.AppContent(expected) + every { setup.navigateTo(any()) } answers { destination = firstArg() } + recoverWolfskinSession(context, IOException("offline"), expected, repository, setup) + assertTrue(destination is SetupDestination.AppContent) + WolfskinScreenPreference.enabled = false + recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup) + assertTrue(destination is SetupDestination.AppContent) + WolfskinScreenPreference.enabled = true + val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel")) + recent.save(CurrentUser(server, other)) + repository.restoreSavedSession() + recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup) + assertTrue(destination is SetupDestination.AppContent) + recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup) + model.load((destination as SetupDestination.UserList).server) + assertEquals(Stage.Picker, model.stage) + assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() }) + assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId) + } finally { WolfskinScreenPreference.enabled = previous } + } + + @Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking { + database.serverDao().deleteUser(server.id, user.id) + database.serverDao().deleteServer(server.id) + recent.clear() + model.load(null) + assertEquals(Stage.Unconfigured, model.stage) + coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList() + model.connect("bad-url") + assertEquals(Stage.Unconfigured, model.stage) + assertTrue(database.serverDao().getServers().isEmpty()) + coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo( + address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(), + systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true)))) + model.connect(server.url) + assertEquals(server.id, database.serverDao().getServers().single().server.id) + coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto)) + model.load(model.server) + assertEquals(Stage.Picker, model.stage) + model.select(model.picker!!.person) + assertEquals(Stage.Login, model.stage) + assertNull(repository.current.value) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 074f312e..bacbc39a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -225,7 +225,11 @@ class WolfskinShellViewModel @Inject constructor( val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } } - if (saved == null || saved.isProtected) return@launch + if (saved == null || saved.isProtected) { + if (ConductarrPreference.fixtures) return@launch + setupNavigation.navigateTo(SetupDestination.UserList(server)) + return@launch + } try { val current = repository.changeUser(server, saved) coroutineContext.ensureActive() @@ -233,7 +237,7 @@ class WolfskinShellViewModel @Inject constructor( } catch (exception: Exception) { if (exception is kotlinx.coroutines.CancellationException) throw exception coroutineContext.ensureActive() - session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } + setupNavigation.navigateTo(SetupDestination.UserList(server)) } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt new file mode 100644 index 00000000..88c89ae5 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSessionRecovery.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import org.jellyfin.sdk.api.client.exception.InvalidStatusException + +/** Only an expired current login leaves saved content; outages and late failures keep it usable. */ +fun recoverWolfskinSession( + context: Context, + exception: Exception, + expected: CurrentUser?, + repository: ServerRepository, + setup: SetupNavigationManager, +) { + if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return + WolfskinScreenPreference.initialize(context) + if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server)) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt new file mode 100644 index 00000000..c7b16b3f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupContent.kt @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Text +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 org.justinvisser.wolfskin.design.ShellAction +import org.justinvisser.wolfskin.design.ShellSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.justinvisser.wolfskin.design.Wordmark +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage + +/** Intercept both stock setup destinations before either upstream selector can be composed. */ +@Composable +fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean { + if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false + val context = LocalContext.current + remember(context) { + WolfskinScreenPreference.initialize(context) + ConductarrPreference.initialize(context) + true + } + if (!WolfskinScreenPreference.enabled) return false + WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel()) + return true +} + +/** Saved people remain local; network failures expose retry and real server authentication. */ +@Composable +internal fun WolfskinSetupScreen( + destination: SetupDestination, + modifier: Modifier = Modifier, + model: WolfskinSetupViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val hint = (destination as? SetupDestination.UserList)?.server + LaunchedEffect(destination) { model.load(hint) } + val retrySetup: () -> Unit = { scope.launch { model.retry() } } + model.onAction = { action -> + when (action) { + is ShellAction.Person -> scope.launch { model.select(action.person) } + is ShellAction.Destination -> model.showSettings() + else -> Unit + } + } + BackHandler { + if (model.pinUser != null) model.dismissPin() + else model.returnToPicker() + } + WolfskinTheme { + val picker = model.picker + if (model.stage == Stage.Picker && picker != null) { + ShellSurface(picker, modifier.testTag("wolfskin-setup-picker")) + } else { + SetupPanel(model.stage, modifier) { + when (model.stage) { + Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body) + Stage.Unconfigured -> ServerForm(model) + Stage.Login -> LoginForm(model) + Stage.Settings -> { + SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) } + SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker) + } + Stage.Unavailable -> { + Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup) + if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) } + } + } + Stage.Picker -> Unit + } + } + } + model.pinUser?.let { user -> + PinEntryDialog( + onDismissRequest = model::dismissPin, + onClickServerAuth = { model.showLogin(user) }, + onTextChange = { value -> scope.launch { model.enterPin(value) } }, + ) + } + } +} + +@Composable +private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) { + Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) { + Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) + Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth), + verticalArrangement = Arrangement.spacedBy(S.panelGap)) { + Text(when (stage) { + Stage.Unconfigured -> "Connect your household server" + Stage.Login -> "Sign in to your profile" + Stage.Settings -> "Settings" + else -> "Who’s watching?" + }, color = C.fg, style = WolfskinTokens.Type.screenTitle) + content() + } + } +} + +@Composable +private fun ServerForm(model: WolfskinSetupViewModel) { + var url by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body) + EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) + model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) } + SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) { + scope.launch { model.connect(url) } + } +} + +@Composable +private fun LoginForm(model: WolfskinSetupViewModel) { + var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) } + var password by remember(model.loginUser) { mutableStateOf("") } + val scope = rememberCoroutineScope() + val focus = remember { FocusRequester() } + val initialUsername = remember(model.loginUser) { username.isBlank() } + LaunchedEffect(model.loginUser) { focus.requestFocus() } + Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body) + Text("Username", color = C.fg, style = WolfskinTokens.Type.body) + EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth() + .then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username")) + Text("Password", color = C.fg, style = WolfskinTokens.Type.body) + EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth() + .then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) + model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) } + SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) { + val submitted = password + password = "" + scope.launch { model.login(username, submitted) } + } +} + +@Composable +private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() } + Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus), + shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)), + colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg, + focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) { + Text(label, style = WolfskinTokens.Type.button) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt new file mode 100644 index 00000000..2bf9119d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinSetupViewModel.kt @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.justinvisser.wolfskin.design.ShellAction +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState + +/** Setup never clears the remembered household endpoint or login merely to show the picker. */ +@HiltViewModel +class WolfskinSetupViewModel @Inject constructor( + private val repository: ServerRepository, + private val navigation: NavigationManager, + private val setup: SetupNavigationManager, + private val session: WolfskinSession, + private val conductarr: ConductarrSession, +) : ViewModel() { + internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable } + + internal var stage by mutableStateOf(Stage.Loading) + private set + internal var server by mutableStateOf(null) + private set + internal var picker by mutableStateOf(null) + private set + internal var message by mutableStateOf(null) + private set + internal var busy by mutableStateOf(false) + private set + internal var pinUser by mutableStateOf(null) + private set + internal var loginUser by mutableStateOf(null) + private set + private var users: List = emptyList() + internal var onAction: (ShellAction) -> Unit = {} + + /** Recover Room's saved household server even if the old selector erased its preference. */ + internal suspend fun load(hint: JellyfinServer?) = perform { + conductarr.close() + server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) { + is RestoredSession.ServerOnly -> recent.server + is RestoredSession.Success -> recent.currentUser.server + RestoredSession.None -> withContext(Dispatchers.IO) { + repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server + } + } + val household = server + if (household == null) { + stage = Stage.Unconfigured + return@perform + } + users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() } + if (users.isEmpty()) { + users = withContext(Dispatchers.IO) { + repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map { + JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null) + } + } + } + if (users.isEmpty()) { + showLogin(null) + } else { + val people = users.map { ShellPerson(it.id.toString(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) } + picker = ShellState(people, people.first().key) { onAction(it) } + stage = Stage.Picker + } + } + + /** Retry the startup server/profile recovery, never the failed saved-token request. */ + internal suspend fun retry() { + if (busy) return + stage = Stage.Loading + load(null) + } + + /** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */ + internal suspend fun connect(url: String) = perform { + val household = withContext(Dispatchers.IO) { + val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim()) + .sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD } + val info = candidate?.systemInfo?.getOrNull() + val id = info?.id?.toUUIDOrNull() + require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." } + JellyfinServer(id, info.serverName, candidate.address, info.version).also { + repository.addAndChangeServer(it) + } + } + server = household + setup.navigateTo(SetupDestination.UserList(household)) + } + + /** Protected saved users must authenticate before changeUser is allowed to run. */ + internal suspend fun select(person: ShellPerson) { + picker?.pickerVisible = true + if (busy) return + val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return + when { + selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected) + selected.hasPin -> pinUser = selected + else -> changeUser(selected) + } + } + + internal fun showLogin(user: JellyfinUser?) { + pinUser = null + loginUser = user + message = null + stage = Stage.Login + } + + internal fun dismissPin() { pinUser = null } + + internal fun showSettings() { + picker?.pickerVisible = true + if (!busy) stage = Stage.Settings + } + + internal fun returnToPicker() { + if (busy || picker == null) return + picker?.pickerVisible = true + stage = Stage.Picker + } + + internal suspend fun enterPin(value: String) { + val selected = pinUser ?: return + if (value == selected.pin) { + pinUser = null + changeUser(selected) + } + } + + private suspend fun changeUser(user: JellyfinUser) = perform { + try { + complete(repository.changeUser(requireNotNull(server), user)) + } catch (exception: InvalidStatusException) { + if (exception.status != 401) throw exception + showLogin(user) + message = "Your saved sign-in expired. Sign in again to continue." + } + } + + /** A password is sent only to the saved household server and never retained by this model. */ + internal suspend fun login(username: String, password: String) = perform { + val household = requireNotNull(server) + val current = withContext(Dispatchers.IO) { + val result = repository.jellyfin.createApi(household.url).userApi + .authenticateUserByName(username = username, password = password).content + repository.changeUser(household.url, result, loginUser) + } + complete(current) + } + + private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) { + if (ConductarrPreference.fixtures) { + session.state = null + } else { + val saved = users.filterNot { it.id == current.user.id } + current.user + val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) } + session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply { + pickerVisible = false + pickerLaunch = false + } + } + session.serverId = current.server.id.toString() + session.fixtureMode = ConductarrPreference.fixtures + navigation.reloadHome() + setup.navigateTo(SetupDestination.AppContent(current)) + } + + private suspend fun perform(action: suspend () -> Unit) { + if (busy) return + busy = true + message = null + try { + action() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again." + else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again." + else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again." + else if (server == null) "Could not read the saved household server. Try again." + else "The household server didn’t answer. Try again or sign in with your server credentials." + if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable + } finally { + busy = false + } + } +} diff --git a/docker/README.md b/docker/README.md index f36b7f64..490662f4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -270,6 +270,33 @@ household labels, requester capabilities and library audiences after sign-in. The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used only when the Advanced debug fixture switch is enabled; release excludes it. +### Household setup and sign-in recovery + +With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup +destinations before their stock server/user selectors can render. Cold start, +restart with automatic sign-in disabled, the old drawer's user action, legacy +sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's +own picker. Entering it does not call `SwitchUserViewModel.init` or clear the +remembered server/user preference. + +Setup reuses the supplied/current/recent household server, recovering the most +recently used Room server if the legacy picker erased the preference. Saved people +render without a network request. Only an install with no saved server gets the +one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room +persistence, not a new compiled endpoint. Conductarr remains on its existing +configured URL and enriches the signed-in shell as before. + +Selecting a protected profile still requires its PIN or server credentials. +Expired credentials during saved-session revalidation or foreground refresh +return to the picker without allowing a late failure to replace another person's +session. A failed selection offers Wolfskin **Try again** and **Sign in** actions; +hidden/unsaved profiles can use username/password sign-in through the setup +picker's Settings gear. An outage never replaces the saved household endpoint. + +Turning **Wolfskin screens** off retains the unchanged upstream +`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug +fixtures still seed the existing fixture picker after authentication. + Person selection immediately clears the old Conductarr identity and Jellyfin read gate before saved-login I/O. Detached clients are disposed exactly once on the application's IO scope, after the replacement (or signed-out) identity is