Route Wolfskin setup through the household profile picker
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
This commit is contained in:
parent
2b142af8d1
commit
a5b5e92017
10 changed files with 983 additions and 2 deletions
|
|
@ -33,6 +33,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.RestoredSession
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -479,11 +480,13 @@ class MainActivityViewModel
|
||||||
fun appResume() {
|
fun appResume() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
val expected = serverRepository.current.value
|
||||||
try {
|
try {
|
||||||
Timber.v("Updating userDto")
|
Timber.v("Updating userDto")
|
||||||
serverRepository.updateUserDto()
|
serverRepository.updateUserDto()
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Error during appResume")
|
Timber.w(ex, "Error during appResume")
|
||||||
|
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,6 +593,7 @@ class MainActivityViewModel
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
Timber.w(exception, "Saved launch session revalidation failed")
|
Timber.w(exception, "Saved launch session revalidation failed")
|
||||||
|
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
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.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
|
@ -48,6 +49,7 @@ fun MainContent(
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
||||||
) {
|
) {
|
||||||
val preferences by rememberUpdatedState(userPreferences)
|
val preferences by rememberUpdatedState(userPreferences)
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -72,6 +74,7 @@ fun MainContent(
|
||||||
entryProvider = { key ->
|
entryProvider = { key ->
|
||||||
key as SetupDestination
|
key as SetupDestination
|
||||||
NavEntry(key) {
|
NavEntry(key) {
|
||||||
|
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
|
||||||
when (key) {
|
when (key) {
|
||||||
SetupDestination.Loading -> {
|
SetupDestination.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -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<ComponentActivity>()
|
||||||
|
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<ServerRepository>(relaxed = true)
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
|
||||||
|
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||||
|
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
|
||||||
|
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||||
|
private val intentService = mockk<IntentService>()
|
||||||
|
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<CurrentUser?>(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<ScreensaverService>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Application>()
|
||||||
|
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
|
||||||
|
name = TestActivity::class.java.name
|
||||||
|
packageName = context.packageName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
|
||||||
|
@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<UserApi>()
|
||||||
|
val system = mockk<SystemApi>()
|
||||||
|
val quickConnect = mockk<QuickConnectApi>()
|
||||||
|
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>(SetupDestination.ServerList)
|
||||||
|
val screensaver = mockk<ScreensaverService>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Application>()
|
||||||
|
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<ApiClient>(relaxed = true)
|
||||||
|
private val usersApi = mockk<UserApi>()
|
||||||
|
private val systemApi = mockk<SystemApi>()
|
||||||
|
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<SetupNavigationManager>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -225,7 +225,11 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
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 {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|
@ -233,7 +237,7 @@ 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()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
setupNavigation.navigateTo(SetupDestination.UserList(server))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<JellyfinServer?>(null)
|
||||||
|
private set
|
||||||
|
internal var picker by mutableStateOf<ShellState?>(null)
|
||||||
|
private set
|
||||||
|
internal var message by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
internal var busy by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
|
private set
|
||||||
|
private var users: List<JellyfinUser> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -241,6 +241,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
|
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.
|
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
|
Person selection immediately clears the old Conductarr identity and Jellyfin
|
||||||
read gate before saved-login I/O. Detached clients are disposed exactly once on
|
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
|
the application's IO scope, after the replacement (or signed-out) identity is
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue