Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64

Merged
justinzeus merged 25 commits from fm/tv-round2-rc into fm/tv-round2 2026-11-07 21:14:15 +01:00
10 changed files with 983 additions and 2 deletions
Showing only changes of commit a5b5e92017 - Show all commits

View file

@ -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)
}
}
}

View file

@ -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(

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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))
}
}
}

View file

@ -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))
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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
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