Limit auto-off warm continuation to trailer handoffs
All checks were successful
review/delta-claude Delta check: trailer-only narrowing correct, no safeguard weakened, 0 blockers

This commit is contained in:
justinzeus 2026-11-08 13:08:05 +01:00
parent 1bdae30e11
commit 98db417ca2
11 changed files with 231 additions and 23 deletions

View file

@ -57,6 +57,7 @@ class InstrumentedBasicUiTests {
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
liveSession = MutableStateFlow(null),
trailerReturn = mockk(relaxed = true),
modifier = Modifier,
)
}

View file

@ -274,6 +274,7 @@ class MainActivity : AppCompatActivity() {
backdropService = backdropService,
screensaverService = screensaverService,
liveSession = viewModel.serverRepository.current,
trailerReturn = trailerReturn,
modifier = Modifier.fillMaxSize(),
)
}
@ -310,7 +311,6 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
trailerReturn.clear()
viewModel.appStart(null, resuming = true)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import timber.log.Timber
@ -51,6 +52,8 @@ class MainActivityViewModel
private var startupJob: kotlinx.coroutines.Job? = null
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) {
// onResume clears the pending handoff before asynchronous startup necessarily runs.
val handoff = if (resuming && savedState == null) trailerReturn.pendingHandoff() else null
if (!resuming) trailerReturn.clear()
startupJob?.cancel()
startupJob = viewModelScope.launchDefault {
@ -80,7 +83,7 @@ class MainActivityViewModel
}
try {
handleAppStart(resuming, savedState)
handleAppStart(resuming, savedState, handoff)
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during appStart")
@ -184,7 +187,11 @@ class MainActivityViewModel
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart(resuming: Boolean = false, savedState: Bundle? = null) {
internal suspend fun handleAppStart(
resuming: Boolean = false,
savedState: Bundle? = null,
handoff: TrailerReturnRecord? = trailerReturn.pendingHandoff(),
) {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
val owner = setupNavigationManager.backStack.lastOrNull()
@ -213,7 +220,9 @@ class MainActivityViewModel
return
}
// A picker may be an explicit switch or expired login, even while a user remains cached.
if ((signInAutomatically || (resuming && canContinue)) && currentUser != null && !currentUser.user.isProtected) {
val continuingTrailer = resuming && savedState == null && canContinue &&
trailerReturn.canContinue(handoff, currentUser, navigationManager.backStack.lastOrNull())
if ((signInAutomatically || continuingTrailer) && currentUser != null && !currentUser.user.isProtected) {
withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))

View file

@ -38,6 +38,7 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import kotlinx.coroutines.flow.StateFlow
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
@Composable
@ -48,6 +49,7 @@ fun MainContent(
backdropService: BackdropService,
screensaverService: ScreensaverService,
liveSession: StateFlow<CurrentUser?>,
trailerReturn: TrailerReturnState,
modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) {
@ -104,7 +106,8 @@ fun MainContent(
backdropService.clearBackdrop()
}
val current = key.current
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) {
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically,
trailerReturn, { navigationManager.backStack.lastOrNull() }) {
WolfskinApp(
user = current.user,
server = current.server,

View file

@ -16,27 +16,34 @@ import androidx.tv.material3.MaterialTheme
import androidx.lifecycle.compose.LifecycleEventEffect
import com.github.damontecres.wholphin.data.CurrentUser
import kotlinx.coroutines.flow.StateFlow
import com.github.damontecres.wholphin.ui.nav.Destination
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */
/** Only a pending trailer handoff keeps an auto-off session mounted across a stopped activity. */
@Composable
internal fun SignedInContent(
current: CurrentUser,
liveSession: StateFlow<CurrentUser?>,
signInAutomatically: Boolean,
trailerReturn: TrailerReturnState,
destination: () -> Destination?,
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) }
var handoff by remember(current.server.id, current.user.id) { mutableStateOf<TrailerReturnRecord?>(null) }
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
live.user.id == current.user.id && trailerReturn.canContinue(handoff, live, destination())
if (!canContinue) showContent = false
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
hasStopped = true
handoff = trailerReturn.pendingHandoff()
hideInvalidSession()
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {

View file

@ -20,13 +20,16 @@ import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */
/** Only a valid trailer handoff continues an auto-off live identity; cold and locked entries stay gated. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityResumeTest {
private val dispatcher = StandardTestDispatcher()
@ -38,8 +41,16 @@ class MainActivityResumeTest {
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 var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn)
@Before fun configure() {
WholphinDispatchers.configure(dispatcher)
@ -52,16 +63,109 @@ class MainActivityResumeTest {
@After fun reset() { WholphinDispatchers.reset() }
@Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) {
@Test fun ordinaryWarmReturnShowsThePickerWithAutomaticSignInOff() = 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())
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun trailerReturnKeepsTheSamePersonAndTitleButTheNextOrdinaryReturnShowsThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = true)
verify(exactly = 1) { setup.navigateTo(SetupDestination.AppContent(current)) }
assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList())
clearMocks(setup, answers = false)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun onResumeCleanupCannotEraseTheHandoffCapturedBeforeAsynchronousStartup() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
trailerReturn.finishHandoff()
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.getMostRecentServer() }
}
@Test fun expiredOrDifferentBootHandoffsStillShowThePicker() = runTest(dispatcher) {
navigation.backStack.add(title)
for ((age, returningBoot) in listOf(600_000L to 7, 1L to 8, 1L to -1)) {
elapsed = 1_000L
boot = 7
trailerReturn.prepare(current, title)
elapsed += age
boot = returningBoot
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
@Test fun protectionAddedDuringAPendingHandoffStillRequiresUnlocking() = runTest(dispatcher) {
navigation.backStack.add(title)
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) {
trailerReturn.prepare(current, title)
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 anotherPersonOrTitleCannotReuseAPendingHandoff() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
navigation.backStack.add(title)
trailerReturn.prepare(other, title)
model.handleAppStart(resuming = true)
trailerReturn.prepare(current, Destination.Wolfskin("title/${UUID.randomUUID()}"))
model.handleAppStart(resuming = true)
verify(exactly = 2) { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnStillContinuesTheLivePerson() = runTest(dispatcher) {
automatic.value = automatic.value.update { signInAutomatically = true }
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun coldLaunchCannotUseAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.handleAppStart(resuming = false)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun anExplicitPickerCannotBeDismissedByAPendingLiveHandoff() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
every { setup.backStack } returns mutableListOf(SetupDestination.UserList(current.server))
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@Test fun theHandoffDeadlineIsRecheckedAfterAsynchronousStartupHasWaited() = runTest(dispatcher) {
navigation.backStack.add(title)
trailerReturn.prepare(current, title)
model.appStart(null, resuming = true)
elapsed += 600_000L
advanceUntilIdle()
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
@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))) {

View file

@ -12,7 +12,10 @@ 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.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
@ -24,12 +27,14 @@ import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.TitleFixture
import org.justinvisser.wolfskin.tv.TitleTrailer
import org.justinvisser.wolfskin.tv.TrailerReturnClock
import org.justinvisser.wolfskin.tv.TrailerReturnState
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. */
/** Only the first valid trailer return keeps an auto-off title visible; other returns re-enter setup. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@ -38,18 +43,30 @@ class SignedInContentTest {
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 var elapsed = 1_000L
private var boot = 7
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } answers { boot }
}
private val trailerReturn = TrailerReturnState(clock)
private lateinit var destination: Destination
private fun mount(person: JellyfinUser = user) {
private fun mount(person: JellyfinUser = user, automatic: Boolean = false) {
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")
destination = Destination.Wolfskin("title/${data.item.id}")
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) {})
if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {
trailerReturn.prepare(liveSession.value, destination)
})
}
rule.setContent {
WolfskinTheme {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = automatic,
trailerReturn, { destination }) {
TitleSurface(title, shell)
}
}
@ -59,6 +76,8 @@ class SignedInContentTest {
private fun stopAndReturn() {
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
// Startup or onResume may clear the marker before the returning composition catches up.
trailerReturn.finishHandoff()
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
}
@ -78,6 +97,57 @@ class SignedInContentTest {
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
}
@Test fun aSecondOrdinaryReturnAfterTheTrailerHidesTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun handoffExpiringWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
elapsed += 600_000L
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun changedBootWhileAwayDoesNotRevealTheTitle() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
boot += 1
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun requireLoginAddedDuringAHandoffKeepsTheTitleHidden() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(requireLogin = true))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun ordinaryWarmReturnWithAutomaticSignInOnKeepsTheTitleVisible() {
mount(automatic = true)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed()
}
@Test fun ordinaryWarmReturnHidesTheTitleWithAutomaticSignInOff() {
mount()
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
mount(user.copy(pin = "1234"))
stopAndReturn()
@ -92,6 +162,7 @@ class SignedInContentTest {
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
mount()
trailerReturn.prepare(liveSession.value, destination)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
@ -99,6 +170,7 @@ class SignedInContentTest {
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
mount()
trailerReturn.prepare(liveSession.value, destination)
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)

View file

@ -101,7 +101,7 @@ class WolfskinSetupNavigationTest {
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier,
mockk(relaxed = true), screensaver, liveSession = repository.current, trailerReturn = mockk(relaxed = true), modifier = Modifier,
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))
rule.setContent {
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null))
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true))
}
fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) {

View file

@ -47,6 +47,15 @@ class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClo
pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot)
}
/** Snapshot before lifecycle cleanup; asynchronous startup must still recheck identity and expiry. */
@Synchronized
internal fun pendingHandoff(): TrailerReturnRecord? = pending
/** A live return has the same identity, title and deadline constraints as its saved task. */
@Synchronized
internal fun canContinue(record: TrailerReturnRecord?, current: CurrentUser?, destination: Destination?): Boolean =
record != null && matches(record, current) && record.route == titleDestination(destination) && isWithinWindow(record)
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
@Synchronized
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
@ -136,7 +145,7 @@ class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClo
}
@Serializable
private data class TrailerReturnRecord(
internal data class TrailerReturnRecord(
val serverId: String,
val userId: String,
val route: String,