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
All checks were successful
review/delta-claude Delta check: trailer-only narrowing correct, no safeguard weakened, 0 blockers
This commit is contained in:
parent
3a315eb809
commit
4a6d318f8f
12 changed files with 232 additions and 24 deletions
|
|
@ -57,6 +57,7 @@ class InstrumentedBasicUiTests {
|
|||
backdropService = mockk(relaxed = true),
|
||||
screensaverService = screensaverService,
|
||||
liveSession = MutableStateFlow(null),
|
||||
trailerReturn = mockk(relaxed = true),
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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
|
||||
|
|
@ -51,6 +52,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) },
|
||||
) {
|
||||
|
|
@ -110,7 +112,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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ class NavigationCutTest {
|
|||
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
|
||||
com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow<com.github.damontecres.wholphin.data.CurrentUser?>(null),
|
||||
mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow<com.github.damontecres.wholphin.data.CurrentUser?>(null), mockk(relaxed = true),
|
||||
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||
setupInterceptor = { destination, modifier ->
|
||||
androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue