Keep live title sessions when returning from trailer apps

This commit is contained in:
justinzeus 2026-11-08 02:46:13 +01:00
parent 12a81ca8e1
commit b6665d016b
8 changed files with 281 additions and 34 deletions

View file

@ -53,9 +53,10 @@ class InstrumentedBasicUiTests {
MainContent( MainContent(
backStack = mutableListOf(SetupDestination.ServerList), backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = navigationManager, navigationManager = navigationManager,
appPreferences = mockk(relaxed = true), userPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true), backdropService = mockk(relaxed = true),
screensaverService = screensaverService, screensaverService = screensaverService,
liveSession = MutableStateFlow(null),
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -233,7 +233,7 @@ class MainActivity : AppCompatActivity() {
playerBackend = prefs.playbackPreferences.playerBackend playerBackend = prefs.playbackPreferences.playerBackend
} }
viewModel.appStart(intent) viewModel.appStart(intent, resuming = savedInstanceState != null)
setContent { setContent {
MaterialTheme { MaterialTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
@ -283,6 +283,7 @@ class MainActivity : AppCompatActivity() {
userPreferences = userPreferences, userPreferences = userPreferences,
backdropService = backdropService, backdropService = backdropService,
screensaverService = screensaverService, screensaverService = screensaverService,
liveSession = viewModel.serverRepository.current,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@ -318,7 +319,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() { override fun onRestart() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart(null) viewModel.appStart(null, resuming = true)
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page // If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull() val lastDest = navigationManager.backStack.lastOrNull()
@ -440,7 +441,7 @@ class MainActivityViewModel
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex() private val mutex = Mutex()
fun appStart(intent: Intent?) { fun appStart(intent: Intent?, resuming: Boolean = false) {
viewModelScope.launchDefault { viewModelScope.launchDefault {
mutex.withLock { mutex.withLock {
try { try {
@ -464,7 +465,7 @@ class MainActivityViewModel
} }
try { try {
handleAppStart() handleAppStart(resuming)
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during appStart") Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList) setupNavigationManager.navigateTo(SetupDestination.ServerList)
@ -567,11 +568,16 @@ class MainActivityViewModel
* Handle starting the app including auto sign in and navigating to the right starting page * Handle starting the app including auto sign in and navigating to the right starting page
*/ */
@VisibleForTesting @VisibleForTesting
internal suspend fun handleAppStart() { internal suspend fun handleAppStart(resuming: Boolean = false) {
val signInAutomatically = preferences.data.first().signInAutomatically val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { // A picker may be an explicit switch or expired login, even while a user remains cached.
// Hot reload val shown = if (resuming && !signInAutomatically) {
(setupNavigationManager.backStack.lastOrNull() as? SetupDestination.AppContent)?.current
} else null
val canContinue = currentUser != null && shown != null &&
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
if ((signInAutomatically || canContinue) && currentUser != null && !currentUser.user.isProtected) {
Timber.v("Hot reload restoring state") Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo( setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser), SetupDestination.AppContent(currentUser),

View file

@ -11,17 +11,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -29,6 +25,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverService
@ -40,7 +37,9 @@ 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
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import kotlinx.coroutines.flow.StateFlow
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
@Composable @Composable
fun MainContent( fun MainContent(
backStack: MutableList<SetupDestination>, backStack: MutableList<SetupDestination>,
@ -48,6 +47,7 @@ fun MainContent(
userPreferences: UserPreferences, userPreferences: UserPreferences,
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
liveSession: StateFlow<CurrentUser?>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) { ) {
@ -104,16 +104,7 @@ fun MainContent(
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
val current = key.current val current = key.current
var showContent by remember { SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
WolfskinApp( WolfskinApp(
user = current.user, user = current.user,
server = current.server, server = current.server,
@ -121,16 +112,6 @@ fun MainContent(
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
} }
} }
} }

View file

@ -0,0 +1,56 @@
package com.github.damontecres.wholphin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.tv.material3.MaterialTheme
import androidx.lifecycle.compose.LifecycleEventEffect
import com.github.damontecres.wholphin.data.CurrentUser
import kotlinx.coroutines.flow.StateFlow
/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */
@Composable
internal fun SignedInContent(
current: CurrentUser,
liveSession: StateFlow<CurrentUser?>,
signInAutomatically: Boolean,
content: @Composable () -> Unit,
) {
var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) }
var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) }
fun hideInvalidSession() {
if (!signInAutomatically) {
val live = liveSession.value
val canContinue = live != null && live.server.id == current.server.id &&
live.user.id == current.user.id && !live.user.isProtected
if (!canContinue) showContent = false
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
hasStopped = true
hideInvalidSession()
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
// A newly unlocked entry may paint; a returning entry must recheck protection changed while away.
if (hasStopped) {
hideInvalidSession()
hasStopped = false
}
}
if (showContent) {
content()
} else {
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center))
}
}
}

View file

@ -0,0 +1,95 @@
package com.github.damontecres.wholphin
import androidx.datastore.core.DataStore
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.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
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.nav.Destination
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityResumeTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<ServerRepository>()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val navigation = NavigationManager()
private val preferences = mockk<DataStore<AppPreferences>>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val current = CurrentUser(server,
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token"))
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true))
@Before fun configure() {
WholphinDispatchers.configure(dispatcher)
every { preferences.data } returns automatic
every { repository.current } returns MutableStateFlow(current)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current))
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server)
coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server)
}
@After fun reset() { WholphinDispatchers.reset() }
@Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) {
val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
navigation.backStack.add(title)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList())
coVerify(exactly = 0) { repository.restoreSavedSession() }
coVerify(exactly = 0) { repository.getMostRecentServer() }
}
@Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) {
for (enabled in listOf(false, true)) {
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
automatic.value = automatic.value.update { signInAutomatically = enabled }
val protected = current.copy(user = user)
every { repository.current } returns MutableStateFlow(protected)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) }
}
}
}
@Test fun recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) {
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) {
every { setup.backStack } returns mutableListOf(shown)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
}

View file

@ -0,0 +1,108 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.Lifecycle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.TitleFixture
import org.justinvisser.wolfskin.tv.TitleTrailer
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The signed-in title survives a stopped activity without removing protected-person gates. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SignedInContentTest {
@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 liveSession = MutableStateFlow<CurrentUser?>(CurrentUser(server, user))
private fun mount(person: JellyfinUser = user) {
liveSession.value = CurrentUser(server, person)
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij")
val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
val title = TitleState(data, shell.person) { action ->
if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url))
}
rule.setContent {
WolfskinTheme {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) {
TitleSurface(title, shell)
}
}
}
rule.onNodeWithTag("title-heading").assertTextEquals("Locke")
}
private fun stopAndReturn() {
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
}
@Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij"))
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler)
mount()
rule.onNodeWithTag("title-option-trailer").performClick()
assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
}
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
mount(user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() {
mount(user.copy(requireLogin = true))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
mount()
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
mount()
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
}

View file

@ -99,7 +99,7 @@ class WolfskinSetupNavigationTest {
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent { rule.setContent {
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, Modifier, mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier,
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
} }
} }

View file

@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest {
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent { rule.setContent {
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver) mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null))
} }
fun awaitHeading(resource: Int) { fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) { rule.waitUntil(5_000) {