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,

View file

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