From 98db417ca29ddb1b8684007abc11d98007958670 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 13:08:05 +0100 Subject: [PATCH] Limit auto-off warm continuation to trailer handoffs --- .../wholphin/test/InstrumentedBasicUiTests.kt | 1 + .../damontecres/wholphin/MainActivity.kt | 2 +- .../wholphin/MainActivityViewModel.kt | 15 ++- .../damontecres/wholphin/MainContent.kt | 5 +- .../damontecres/wholphin/SignedInContent.kt | 11 +- .../wholphin/MainActivityResumeTest.kt | 114 +++++++++++++++++- .../wholphin/SignedInContentTest.kt | 80 +++++++++++- .../tv/WolfskinSetupNavigationTest.kt | 2 +- .../wolfskin/tv/WolfskinSetupRollbackTest.kt | 2 +- .../wolfskin/tv/TrailerReturnState.kt | 11 +- docker/README.md | 11 +- 11 files changed, 231 insertions(+), 23 deletions(-) diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 5ccfa603..f3212c23 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -57,6 +57,7 @@ class InstrumentedBasicUiTests { backdropService = mockk(relaxed = true), screensaverService = screensaverService, liveSession = MutableStateFlow(null), + trailerReturn = mockk(relaxed = true), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 66a2b040..dadf10c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt index 271e47b6..ba2c44a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt @@ -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)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 2b653675..bf42ede3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -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, + 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt index 2d22ded4..781ac28a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt @@ -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, 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(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) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt index ec5143c0..f9e853c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -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().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))) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt index 0b7bb5fe..59e1849b 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -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(server, user)) + private var elapsed = 1_000L + private var boot = 7 + private val clock = mockk().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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index f647b914..775a63ca 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -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) }) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt index b01e145d..3e855e33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -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) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt index 868871ee..0581b74e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt @@ -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, diff --git a/docker/README.md b/docker/README.md index f07b3eb5..e1b72edd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -469,10 +469,13 @@ session. A failed selection offers Wolfskin **Try again** and **Sign in** action hidden/unsaved profiles can use username/password sign-in through the setup picker's Settings gear. An outage never replaces the saved household endpoint. -Returning from an external app such as SmartTube keeps the originating title and -person when the same unprotected identity remains live and its signed-in app -entry is still active, even with automatic sign-in off. Live protection is checked -at stop and again when the stopped entry starts. +With **Sign in automatically** off, ordinary returns from the background show the +profile picker. Only the first return after a trailer handoff keeps the originating +title and person: the same unprotected live identity and active signed-in entry +must match the pending handoff, exact title, same boot and under-ten-minute window. +Live protection is checked at stop and again when the stopped entry starts. The +handoff is cleared on return; a second, ordinary background return shows the picker. +Automatic sign-in on keeps its existing ordinary-return behaviour. If Android kills Wolfskin while the trailer is in front, the same saved Android task can continue for **strictly less than ten minutes from the handoff, on the