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
1bdae30e11
commit
98db417ca2
11 changed files with 231 additions and 23 deletions
|
|
@ -57,6 +57,7 @@ class InstrumentedBasicUiTests {
|
||||||
backdropService = mockk(relaxed = true),
|
backdropService = mockk(relaxed = true),
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
liveSession = MutableStateFlow(null),
|
liveSession = MutableStateFlow(null),
|
||||||
|
trailerReturn = mockk(relaxed = true),
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
backdropService = backdropService,
|
backdropService = backdropService,
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
liveSession = viewModel.serverRepository.current,
|
liveSession = viewModel.serverRepository.current,
|
||||||
|
trailerReturn = trailerReturn,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +311,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.d("onRestart")
|
Timber.d("onRestart")
|
||||||
trailerReturn.clear()
|
|
||||||
viewModel.appStart(null, resuming = true)
|
viewModel.appStart(null, resuming = true)
|
||||||
if (!playExternalViewModel.launched.value) {
|
if (!playExternalViewModel.launched.value) {
|
||||||
// If restarting during playback that is not external, go back a page
|
// If restarting during playback that is not external, go back a page
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.justinvisser.wolfskin.tv.TrailerReturnRecord
|
||||||
import org.justinvisser.wolfskin.tv.TrailerReturnState
|
import org.justinvisser.wolfskin.tv.TrailerReturnState
|
||||||
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -51,6 +52,8 @@ class MainActivityViewModel
|
||||||
private var startupJob: kotlinx.coroutines.Job? = null
|
private var startupJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = 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()
|
if (!resuming) trailerReturn.clear()
|
||||||
startupJob?.cancel()
|
startupJob?.cancel()
|
||||||
startupJob = viewModelScope.launchDefault {
|
startupJob = viewModelScope.launchDefault {
|
||||||
|
|
@ -80,7 +83,7 @@ class MainActivityViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
handleAppStart(resuming, savedState)
|
handleAppStart(resuming, savedState, handoff)
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
if (ex is kotlinx.coroutines.CancellationException) throw ex
|
if (ex is kotlinx.coroutines.CancellationException) throw ex
|
||||||
Timber.e(ex, "Error during appStart")
|
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
|
* Handle starting the app including auto sign in and navigating to the right starting page
|
||||||
*/
|
*/
|
||||||
@VisibleForTesting
|
@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 signInAutomatically = preferences.data.first().signInAutomatically
|
||||||
val currentUser = serverRepository.current.value
|
val currentUser = serverRepository.current.value
|
||||||
val owner = setupNavigationManager.backStack.lastOrNull()
|
val owner = setupNavigationManager.backStack.lastOrNull()
|
||||||
|
|
@ -213,7 +220,9 @@ class MainActivityViewModel
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A picker may be an explicit switch or expired login, even while a user remains cached.
|
// 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) {
|
withContext(WholphinDispatchers.Main.immediate) {
|
||||||
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
|
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
|
||||||
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
|
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
|
||||||
|
|
|
||||||
|
|
@ -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.InterfaceCustomization
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.justinvisser.wolfskin.tv.TrailerReturnState
|
||||||
|
|
||||||
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
|
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -48,6 +49,7 @@ fun MainContent(
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
liveSession: StateFlow<CurrentUser?>,
|
liveSession: StateFlow<CurrentUser?>,
|
||||||
|
trailerReturn: TrailerReturnState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
|
||||||
) {
|
) {
|
||||||
|
|
@ -104,7 +106,8 @@ fun MainContent(
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
}
|
}
|
||||||
val current = key.current
|
val current = key.current
|
||||||
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) {
|
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically,
|
||||||
|
trailerReturn, { navigationManager.backStack.lastOrNull() }) {
|
||||||
WolfskinApp(
|
WolfskinApp(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
|
|
|
||||||
|
|
@ -16,27 +16,34 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import com.github.damontecres.wholphin.data.CurrentUser
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
@Composable
|
||||||
internal fun SignedInContent(
|
internal fun SignedInContent(
|
||||||
current: CurrentUser,
|
current: CurrentUser,
|
||||||
liveSession: StateFlow<CurrentUser?>,
|
liveSession: StateFlow<CurrentUser?>,
|
||||||
signInAutomatically: Boolean,
|
signInAutomatically: Boolean,
|
||||||
|
trailerReturn: TrailerReturnState,
|
||||||
|
destination: () -> Destination?,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) }
|
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 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() {
|
fun hideInvalidSession() {
|
||||||
if (!signInAutomatically) {
|
if (!signInAutomatically) {
|
||||||
val live = liveSession.value
|
val live = liveSession.value
|
||||||
val canContinue = live != null && live.server.id == current.server.id &&
|
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
|
if (!canContinue) showContent = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
hasStopped = true
|
hasStopped = true
|
||||||
|
handoff = trailerReturn.pendingHandoff()
|
||||||
hideInvalidSession()
|
hideInvalidSession()
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,16 @@ import java.util.UUID
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class MainActivityResumeTest {
|
class MainActivityResumeTest {
|
||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
@ -38,8 +41,16 @@ class MainActivityResumeTest {
|
||||||
private val current = CurrentUser(server,
|
private val current = CurrentUser(server,
|
||||||
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token"))
|
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token"))
|
||||||
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
|
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,
|
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() {
|
@Before fun configure() {
|
||||||
WholphinDispatchers.configure(dispatcher)
|
WholphinDispatchers.configure(dispatcher)
|
||||||
|
|
@ -52,16 +63,109 @@ class MainActivityResumeTest {
|
||||||
|
|
||||||
@After fun reset() { WholphinDispatchers.reset() }
|
@After fun reset() { WholphinDispatchers.reset() }
|
||||||
|
|
||||||
@Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) {
|
@Test fun ordinaryWarmReturnShowsThePickerWithAutomaticSignInOff() = runTest(dispatcher) {
|
||||||
val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
|
val title = Destination.Wolfskin("title/${UUID.randomUUID()}")
|
||||||
navigation.backStack.add(title)
|
navigation.backStack.add(title)
|
||||||
model.handleAppStart(resuming = true)
|
model.handleAppStart(resuming = true)
|
||||||
verify { setup.navigateTo(SetupDestination.AppContent(current)) }
|
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
|
||||||
assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList())
|
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
|
||||||
coVerify(exactly = 0) { repository.restoreSavedSession() }
|
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() }
|
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) {
|
@Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) {
|
||||||
for (enabled in listOf(false, true)) {
|
for (enabled in listOf(false, true)) {
|
||||||
for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = 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.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -24,12 +27,14 @@ import org.junit.runner.RunWith
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.TitleFixture
|
import org.justinvisser.wolfskin.tv.TitleFixture
|
||||||
import org.justinvisser.wolfskin.tv.TitleTrailer
|
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.RobolectricTestRunner
|
||||||
import org.robolectric.Shadows.shadowOf
|
import org.robolectric.Shadows.shadowOf
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
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)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
|
@ -38,18 +43,30 @@ class SignedInContentTest {
|
||||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
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 user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
||||||
private val liveSession = MutableStateFlow<CurrentUser?>(CurrentUser(server, user))
|
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)
|
liveSession.value = CurrentUser(server, person)
|
||||||
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||||
val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij")
|
val 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 shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") }
|
||||||
val title = TitleState(data, shell.person) { action ->
|
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 {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) {
|
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = automatic,
|
||||||
|
trailerReturn, { destination }) {
|
||||||
TitleSurface(title, shell)
|
TitleSurface(title, shell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +76,8 @@ class SignedInContentTest {
|
||||||
|
|
||||||
private fun stopAndReturn() {
|
private fun stopAndReturn() {
|
||||||
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
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.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +97,57 @@ class SignedInContentTest {
|
||||||
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
|
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() {
|
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
|
||||||
mount(user.copy(pin = "1234"))
|
mount(user.copy(pin = "1234"))
|
||||||
stopAndReturn()
|
stopAndReturn()
|
||||||
|
|
@ -92,6 +162,7 @@ class SignedInContentTest {
|
||||||
|
|
||||||
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
|
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
|
||||||
mount()
|
mount()
|
||||||
|
trailerReturn.prepare(liveSession.value, destination)
|
||||||
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
|
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
|
||||||
stopAndReturn()
|
stopAndReturn()
|
||||||
rule.onNodeWithTag("title-heading").assertDoesNotExist()
|
rule.onNodeWithTag("title-heading").assertDoesNotExist()
|
||||||
|
|
@ -99,6 +170,7 @@ class SignedInContentTest {
|
||||||
|
|
||||||
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
|
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
|
||||||
mount()
|
mount()
|
||||||
|
trailerReturn.prepare(liveSession.value, destination)
|
||||||
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
|
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
|
||||||
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ class WolfskinSetupNavigationTest {
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
mockk(relaxed = true), screensaver, 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) })
|
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest {
|
||||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null))
|
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true))
|
||||||
}
|
}
|
||||||
fun awaitHeading(resource: Int) {
|
fun awaitHeading(resource: Int) {
|
||||||
rule.waitUntil(5_000) {
|
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)
|
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. */
|
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
|
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
|
||||||
|
|
@ -136,7 +145,7 @@ class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClo
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class TrailerReturnRecord(
|
internal data class TrailerReturnRecord(
|
||||||
val serverId: String,
|
val serverId: String,
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val route: String,
|
val route: String,
|
||||||
|
|
|
||||||
|
|
@ -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
|
hidden/unsaved profiles can use username/password sign-in through the setup
|
||||||
picker's Settings gear. An outage never replaces the saved household endpoint.
|
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
|
With **Sign in automatically** off, ordinary returns from the background show the
|
||||||
person when the same unprotected identity remains live and its signed-in app
|
profile picker. Only the first return after a trailer handoff keeps the originating
|
||||||
entry is still active, even with automatic sign-in off. Live protection is checked
|
title and person: the same unprotected live identity and active signed-in entry
|
||||||
at stop and again when the stopped entry starts.
|
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
|
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
|
task can continue for **strictly less than ten minutes from the handoff, on the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue