Candidate 3: merge tv-bug-trailer-return

This commit is contained in:
root 2026-11-08 06:50:39 +01:00
commit eb3c65af19
36 changed files with 1350 additions and 268 deletions

View file

@ -53,9 +53,10 @@ class InstrumentedBasicUiTests {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = navigationManager,
appPreferences = mockk(relaxed = true),
userPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
liveSession = MutableStateFlow(null),
modifier = Modifier,
)
}

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
@ -9,7 +8,6 @@ import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -24,24 +22,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.RestoredSession
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
@ -66,7 +56,6 @@ import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
@ -74,20 +63,17 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import timber.log.Timber
import org.justinvisser.wolfskin.tv.TrailerReturnState
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@ -156,6 +142,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var intentService: IntentService
@Inject
lateinit var trailerReturn: TrailerReturnState
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
@ -169,6 +158,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
if (savedInstanceState == null) trailerReturn.clear()
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
@ -233,7 +223,7 @@ class MainActivity : AppCompatActivity() {
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart(intent)
viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState)
setContent {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
@ -283,6 +273,7 @@ class MainActivity : AppCompatActivity() {
userPreferences = userPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
liveSession = viewModel.serverRepository.current,
modifier = Modifier.fillMaxSize(),
)
}
@ -309,6 +300,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
Timber.d("onResume")
trailerReturn.finishHandoff()
viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
@ -318,7 +310,8 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart(null)
trailerReturn.clear()
viewModel.appStart(null, resuming = true)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
@ -374,6 +367,7 @@ class MainActivity : AppCompatActivity() {
Timber.w(ex, "Could not persist back stack; skipping")
}
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull())
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -424,182 +418,6 @@ class MainActivity : AppCompatActivity() {
}
}
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
val result = handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
val expected = serverRepository.current.value
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart() {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
// Hot reload
Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser),
)
} else {
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
serverRepository.restoreSavedSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
if (signInAutomatically && restoredSession is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restoredSession.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
}
}
}
}
}
}
private val Destination?.isPlayback: Boolean
get() =

View file

@ -0,0 +1,254 @@
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.tv.TrailerReturnState
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import timber.log.Timber
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
private val mutex = Mutex()
private var startupJob: kotlinx.coroutines.Job? = null
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) {
if (!resuming) trailerReturn.clear()
startupJob?.cancel()
startupJob = viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
// A saved trailer task must not replay its original deep link or authenticate its old intent's person.
val restoringTrailer = resuming && savedState?.containsKey(TrailerReturnState.SAVED_STATE_KEY) == true
val result = if (restoringTrailer) false else handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart(resuming, savedState)
} catch (ex: Exception) {
if (ex is kotlinx.coroutines.CancellationException) throw ex
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
val expected = serverRepository.current.value
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* 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) {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
val owner = setupNavigationManager.backStack.lastOrNull()
val expected = if (resuming && savedState != null) {
trailerReturn.restore(savedState, navigationManager.backStack.lastOrNull())
} else {
trailerReturn.clear()
null
}
val shown = (owner as? SetupDestination.AppContent)?.current
val canContinue = currentUser != null && shown != null &&
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
if (expected != null) {
val restored = when {
currentUser == null && owner == SetupDestination.Loading ->
serverRepository.restoreSavedSession(expected) {
trailerReturn.isRestoring(expected) && serverRepository.current.value == null &&
setupNavigationManager.backStack.lastOrNull() === owner
}
currentUser != null && (canContinue || owner == SetupDestination.Loading) && !currentUser.user.isProtected &&
currentUser.server.id == expected.serverId && currentUser.user.id == expected.userId &&
serverRepository.isSavedIdentity(expected) -> RestoredSession.Success(currentUser)
else -> serverRepository.getMostRecentServer()
}
finishRestore(restored, owner, fromTrailer = true, revalidate = currentUser == null)
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) {
withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
}
}
} else {
val restored = if (signInAutomatically) serverRepository.restoreSavedSession() else serverRepository.getMostRecentServer()
finishRestore(restored, owner, fromTrailer = false, revalidate = true)
}
}
private suspend fun finishRestore(
restored: RestoredSession,
owner: SetupDestination?,
fromTrailer: Boolean,
revalidate: Boolean,
) {
val shown = withContext(WholphinDispatchers.Main.immediate) {
if (setupNavigationManager.backStack.lastOrNull() !== owner) return@withContext false
if (restored is RestoredSession.Success && serverRepository.current.value !== restored.currentUser) return@withContext false
if (fromTrailer && restored is RestoredSession.Success) trailerReturn.confirmRestore(restored.currentUser)
else trailerReturn.clear()
setupNavigationManager.navigateTo(restored.destination)
true
}
if (shown && revalidate && restored is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restored.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restored.currentUser, serverRepository, setupNavigationManager)
}
}
}
}
}

View file

@ -14,17 +14,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@ -32,6 +28,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
@ -43,7 +40,9 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
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
/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */
@Composable
fun MainContent(
backStack: MutableList<SetupDestination>,
@ -51,6 +50,7 @@ fun MainContent(
userPreferences: UserPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
liveSession: StateFlow<CurrentUser?>,
modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) {
@ -110,16 +110,7 @@ fun MainContent(
backdropService.clearBackdrop()
}
val current = key.current
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!userPreferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) {
WolfskinApp(
user = current.user,
server = current.server,
@ -127,16 +118,6 @@ fun MainContent(
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}

View file

@ -0,0 +1,56 @@
package com.github.damontecres.wholphin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.tv.material3.MaterialTheme
import androidx.lifecycle.compose.LifecycleEventEffect
import com.github.damontecres.wholphin.data.CurrentUser
import kotlinx.coroutines.flow.StateFlow
/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */
@Composable
internal fun SignedInContent(
current: CurrentUser,
liveSession: StateFlow<CurrentUser?>,
signInAutomatically: Boolean,
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) }
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
if (!canContinue) showContent = false
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
hasStopped = true
hideInvalidSession()
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
// A newly unlocked entry may paint; a returning entry must recheck protection changed while away.
if (hasStopped) {
hideInvalidSession()
hasStopped = false
}
}
if (showContent) {
content()
} else {
Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center))
}
}
}

View file

@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -120,12 +122,35 @@ class ServerRepository
}
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) {
suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null)
/** A task-bound restore cannot select another saved person or outlive its caller's authorization. */
suspend fun restoreSavedSession(
expected: SavedSessionIdentity,
isStillAuthorized: () -> Boolean,
): RestoredSession = restoreSavedSessionInternal(expected, isStillAuthorized)
/** Clearing/changing the saved login also invalidates a surviving trailer activity's handoff. */
fun isSavedIdentity(expected: SavedSessionIdentity): Boolean =
matchesSavedIdentity(mostRecentServerProvider.get(), expected)
private fun matchesSavedIdentity(recent: MostRecentServer, expected: SavedSessionIdentity): Boolean =
recent is MostRecentServer.ServerAndUser && recent.serverId == expected.serverId && recent.userId == expected.userId
private suspend fun restoreSavedSessionInternal(
expected: SavedSessionIdentity?,
isStillAuthorized: (() -> Boolean)?,
): RestoredSession = withContext(ioDispatcher) {
val recent = mostRecentServerProvider.get()
if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent)
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
val user = saved.users.firstOrNull { it.id == recent.userId }
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server)
if (user == null || user.isProtected || (expected != null && user.accessToken.isNullOrBlank())) {
return@withContext RestoredSession.ServerOnly(saved.server)
}
currentCoroutineContext().ensureActive()
if (isStillAuthorized?.invoke() == false) return@withContext RestoredSession.ServerOnly(saved.server)
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
val current = CurrentUser(saved.server, user)
_currentUserDto.value = null
@ -384,3 +409,6 @@ data class CurrentUser(
val server: JellyfinServer,
val user: JellyfinUser,
)
/** A previously authenticated server/person pair, without credentials or protection state. */
data class SavedSessionIdentity(val serverId: UUID, val userId: UUID)

View file

@ -0,0 +1,95 @@
package com.github.damontecres.wholphin
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainActivityResumeTest {
private val dispatcher = StandardTestDispatcher()
private val repository = mockk<ServerRepository>()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val navigation = NavigationManager()
private val preferences = mockk<DataStore<AppPreferences>>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
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 val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
@Before fun configure() {
WholphinDispatchers.configure(dispatcher)
every { preferences.data } returns automatic
every { repository.current } returns MutableStateFlow(current)
every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current))
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server)
coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server)
}
@After fun reset() { WholphinDispatchers.reset() }
@Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = 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())
coVerify(exactly = 0) { repository.restoreSavedSession() }
coVerify(exactly = 0) { repository.getMostRecentServer() }
}
@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))) {
automatic.value = automatic.value.update { signInAutomatically = enabled }
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 recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) {
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
coVerify(exactly = 0) { repository.restoreSavedSession() }
}
@Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) {
val other = current.copy(user = current.user.copy(id = UUID.randomUUID()))
for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) {
every { setup.backStack } returns mutableListOf(shown)
model.handleAppStart(resuming = true)
verify { setup.navigateTo(SetupDestination.UserList(current.server)) }
verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) }
}
}
}

View file

@ -0,0 +1,376 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.viewModelScope
import androidx.room.Room
import com.github.damontecres.wholphin.data.*
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.successResponse
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.json.JSONObject
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.justinvisser.wolfskin.tv.update.FakeUpdateController
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** New repository and shell instances reproduce process death, not merely activity recreation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MainActivityTrailerRestoreTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", "12.1.0")
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-token")
private val users = mockk<UserApi>()
private val system = mockk<SystemApi>()
private val api = mockk<ApiClient>(relaxed = true)
private val recent = mockk<MostRecentServerProvider>()
private var recentValue: MostRecentServer = MostRecentServer.None
private val preferences = mockk<DataStore<AppPreferences>>()
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
private var elapsed = 1_000L
private val clock = mockk<TrailerReturnClock>().also {
every { it.readElapsedMillis() } answers { elapsed }
every { it.readBootCount() } returns 7
}
private val restoredReturn = TrailerReturnState(clock)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val database by lazy {
Room.inMemoryDatabaseBuilder(rule.activity, AppDatabase::class.java).allowMainThreadQueries().build()
}
private val dao by lazy { spyk(database.serverDao()) }
private val repository by lazy { ServerRepository(rule.activity, mockk(), dao, api, Dispatchers.Unconfined, recent) }
private val intents = mockk<IntentService>(relaxed = true)
private val model by lazy { MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intents, restoredReturn) }
@Volatile private var destination: SetupDestination = SetupDestination.Loading
private val conductarr = ConductarrSession(MemoryCacheStore())
private var shellModel: WolfskinShellViewModel? = null
private var previousFixtures = false
private var previousScreens = true
@Before fun savedIdentityButNoLiveSession() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
dao.addOrUpdateServer(server)
user = dao.addOrUpdateUser(user)
recentValue = MostRecentServer.from(CurrentUser(server, user))
every { recent.get() } answers { recentValue }
every { api.userApi } returns users
every { api.systemApi } returns system
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = user.id, name = "Justin",
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(serverName = server.name, version = server.version))
every { preferences.data } returns automatic
every { setup.backStack } answers { mutableListOf(destination) }
every { setup.navigateTo(any()) } answers { destination = firstArg() }
navigation.backStack.add(title)
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(
Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), handler)
assertNull(repository.current.value)
}
@After fun closeSessions() {
val jobs = listOfNotNull(model.viewModelScope.coroutineContext[Job], shellModel?.viewModelScope?.coroutineContext?.get(Job))
jobs.forEach { it.cancel() }
shadowOf(Looper.getMainLooper()).idle()
runBlocking { jobs.forEach { it.join() } }
conductarr.close()
database.close()
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
}
private fun snapshot(): Bundle {
val oldProcess = TrailerReturnState(clock)
val alreadySignedIn = CurrentUser(server, user)
check(TitleTrailer(rule.activity).open("https://www.youtube.com/watch?v=ab_-defghij") {
oldProcess.prepare(alreadySignedIn, title)
})
return Bundle().also { oldProcess.save(it, alreadySignedIn, title) }
}
private fun restore(saved: Bundle? = snapshot(), resuming: Boolean = true) {
val result = model.viewModelScope.async(Dispatchers.Default) { model.handleAppStart(resuming = resuming, savedState = saved) }
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertGated() {
assertEquals(SetupDestination.UserList(server), destination)
assertNull(repository.current.value)
verify(exactly = 0) { api.update(any(), any()) }
}
private fun loadFreshShell(): ShellState {
val session = WolfskinSession()
val fresh = WolfskinShellViewModel(rule.activity, session, repository, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk<LibraryReadFacade>(relaxed = true),
WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), FakeUpdateController(), restoredReturn)
shellModel = fresh
runBlocking { fresh.load(server, requireNotNull(repository.current.value).user) }
return requireNotNull(session.state)
}
private fun render(shell: ShellState) {
shell.enterDestination(titleRoute("Locke"))
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
val scene = TitleState(fixture.title("Locke", shell.person.key), shell.person) {}
rule.setContent { WolfskinTheme { TitleSurface(scene, shell) } }
}
private fun sameTitleAfterProcessDeath(automaticSignIn: Boolean, capture: String = automaticSignIn.toString()) {
automatic.value = automatic.value.update { signInAutomatically = automaticSignIn }
restore()
val current = requireNotNull(repository.current.value)
assertEquals(SetupDestination.AppContent(current), destination)
assertEquals(server.id, current.server.id)
assertEquals(user.id, current.user.id)
assertEquals(title, navigation.backStack.last())
val shell = loadFreshShell()
render(shell)
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "process-restored-title-$capture.png"))
}
@Test fun processDeathReturnsToTheSamePersonAndTitleWithAutomaticSignInOff() = sameTitleAfterProcessDeath(false)
@Test fun processDeathDoesNotReopenTheFreshShellChooserWithAutomaticSignInOn() = sameTitleAfterProcessDeath(true)
@Test fun protectionAddedWhileTheProcessWasDeadStillRequiresUnlocking() {
val saved = snapshot()
for (protected in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
destination = SetupDestination.Loading
dao.updateUser(protected)
restore(saved)
assertGated()
}
}
@Test fun aDifferentMostRecentPersonCannotReplaceTheTrailerIdentityEvenWithAutomaticSignInOn() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
recentValue = MostRecentServer.from(CurrentUser(server, other))
for (enabled in listOf(false, true)) {
destination = SetupDestination.Loading
automatic.value = automatic.value.update { signInAutomatically = enabled }
restore(saved)
assertGated()
}
}
@Test fun anExistingPickerCannotBeBypassedByATrailerSnapshot() {
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore()
assertGated()
}
@Test fun missingTokenCannotBeRecoveredFromTheSavedTask() {
val saved = snapshot()
dao.updateUser(user.copy(accessToken = null))
restore(saved)
assertGated()
}
@Test fun removedPeopleAreNotRecreatedByTheSavedTask() {
val saved = snapshot()
dao.deleteUser(server.id, user.id)
restore(saved)
assertGated()
}
@Test fun aFreshLaunchCannotUseAPreviousTrailerSnapshot() {
restore(snapshot(), resuming = false)
assertGated()
}
@Test fun aFreshColdLaunchWithoutSavedTaskStateKeepsThePicker() {
restore(saved = null)
assertGated()
}
@Test fun aReturnHoursLaterCannotRestoreThePerson() {
val saved = snapshot()
elapsed += 4L * 60L * 60L * 1000L
restore(saved)
assertGated()
}
@Test fun aDifferentRestoredDestinationCannotReuseTheIdentityRecord() {
val saved = snapshot()
navigation.backStack[navigation.backStack.lastIndex] = Destination.Wolfskin(titleRoute("another-title"))
restore(saved)
assertGated()
}
@Test fun automaticSignInWithoutATrailerRecordStillShowsTheColdChooser() {
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved = null)
render(loadFreshShell())
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun expiryDuringProfileLookupCannotInstallTheSavedToken() {
val saved = snapshot()
every { dao.getServer(server.id) } answers {
elapsed += 600_000L
callOriginal()
}
restore(saved)
assertGated()
}
@Test fun restoringATrailerTaskDoesNotReplayItsOriginalLaunchIntent() {
coEvery { intents.parseIntent(any()) } returns IntentResult.Error("Old launch target expired")
model.appStart(Intent("old.launch.target"), resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertEquals(SetupDestination.AppContent(requireNotNull(repository.current.value)), destination)
assertEquals(user.id, repository.current.value!!.user.id)
assertEquals(title, navigation.backStack.last())
}
@Test fun aNewExplicitLaunchCancelsAnOlderPendingTaskRestore() {
val started = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
var firstRead = true
every { preferences.data } returns kotlinx.coroutines.flow.flow {
if (firstRead) {
firstRead = false
started.complete(Unit)
release.await()
}
emit(automatic.value)
}
model.appStart(null, resuming = true, savedState = snapshot())
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
started.isCompleted
}
model.appStart(null)
release.complete(Unit)
rule.waitUntil(15_000) {
shadowOf(Looper.getMainLooper()).idle()
destination != SetupDestination.Loading
}
assertGated()
}
private fun authenticateSavedPerson(person: JellyfinUser = user) {
recentValue = MostRecentServer.from(CurrentUser(server, person))
coEvery { recent.save(any()) } coAnswers {
recentValue = MostRecentServer.from(firstArg())
}
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = person.id, name = person.name,
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
runBlocking { check(repository.restoreLastSession() is RestoredSession.Success) }
clearMocks(api, answers = false, recordedCalls = true)
}
@Test fun aRecommendationsWorkerRestoredPersonReturnsToTheSameTitleWithoutSigningInAgain() {
authenticateSavedPerson()
sameTitleAfterProcessDeath(false, capture = "worker")
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aWorkerRestoredPersonWithoutTrailerMetadataCannotSkipTheColdPicker() {
authenticateSavedPerson()
restore(saved = null)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aDifferentCurrentWorkerIdentityCannotBeReplacedByTheCapturedPerson() {
val saved = snapshot()
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
authenticateSavedPerson(other)
recentValue = MostRecentServer.from(CurrentUser(server, user))
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(other.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
@Test fun aProtectedCurrentWorkerIdentityStillRequiresUnlocking() {
authenticateSavedPerson()
val saved = snapshot()
for ((pin, login) in listOf("URDL" to false, null to true)) {
destination = SetupDestination.Loading
runBlocking { repository.updateUserAuth(user, pin, login) }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertTrue(repository.current.value!!.user.isProtected)
verify(exactly = 0) { api.update(any(), any()) }
}
}
@Test fun clearingTheSavedLoginInvalidatesAnOtherwiseEligibleTaskSnapshot() {
val saved = snapshot()
recentValue = MostRecentServer.Server(server.id, server.url)
restore(saved)
assertGated()
}
@Test fun aCachedPersonCannotDismissAnExplicitPickerWithSavedTrailerMetadata() {
authenticateSavedPerson()
val saved = snapshot()
destination = SetupDestination.UserList(server)
automatic.value = automatic.value.update { signInAutomatically = true }
restore(saved)
assertEquals(SetupDestination.UserList(server), destination)
assertEquals(user.id, repository.current.value!!.user.id)
verify(exactly = 0) { api.update(any(), any()) }
}
}

View file

@ -0,0 +1,108 @@
package com.github.damontecres.wholphin
import android.app.Application
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
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.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.TitleFixture
import org.justinvisser.wolfskin.tv.TitleTrailer
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. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SignedInContentTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
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 fun mount(person: JellyfinUser = user) {
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")
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) {})
}
rule.setContent {
WolfskinTheme {
SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) {
TitleSurface(title, shell)
}
}
}
rule.onNodeWithTag("title-heading").assertTextEquals("Locke")
}
private fun stopAndReturn() {
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
}
@Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij"))
val handler = ResolveInfo().apply {
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
}
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler)
mount()
rule.onNodeWithTag("title-option-trailer").performClick()
assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`)
stopAndReturn()
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "returned-title.png"))
}
@Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() {
mount(user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() {
mount(user.copy(requireLogin = true))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() {
mount()
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
stopAndReturn()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
@Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() {
mount()
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
liveSession.value = CurrentUser(server, user.copy(pin = "1234"))
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.waitForIdle()
rule.onNodeWithTag("title-heading").assertDoesNotExist()
}
}

View file

@ -72,6 +72,7 @@ class TestMainActivityViewModel {
backdropService,
appUpgradeHandler,
intentService,
trailerReturn = mockk(relaxed = true),
)
private val serverId = UUID.randomUUID()
@ -88,6 +89,7 @@ class TestMainActivityViewModel {
@Before
fun setUp() {
WholphinDispatchers.configure(testDispatcher)
every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
mockkStatic(Toast::class)
@ -167,14 +169,22 @@ class TestMainActivityViewModel {
@Test
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
setupPreferences { signInAutomatically = true }
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
val canonical = MutableStateFlow<CurrentUser?>(null)
every { serverRepository.current } returns canonical
coEvery { serverRepository.restoreSavedSession() } coAnswers {
canonical.value = currentUser
RestoredSession.Success(currentUser)
}
val release = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
val validationStarted = CompletableDeferred<Unit>()
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers {
validationStarted.complete(Unit)
release.await()
}
viewModel.handleAppStart()
runCurrent()
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
assertFalse(release.isCompleted)
Assert.assertTrue(validationStarted.isCompleted)
release.complete(Unit)
}

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()

View file

@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(

View file

@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
try {
val route = titleRoute("movie:42")

View file

@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
@ -112,7 +112,7 @@ class DiscoverRefreshMotionTest {
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
ConductarrSession(MemoryCacheStore()).use { session ->
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", true)
val route = titleRoute("movie-the-amazing-spider-man-2012")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
@ -166,7 +166,7 @@ class DiscoverRefreshMotionTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } returns held
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }

View file

@ -71,7 +71,7 @@ class DiscoverTitleTimingTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}

View file

@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
@ -220,7 +220,7 @@ class PersonSwitchThreadingTest {
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
conductarr.authenticate()

View file

@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
val session = WolfskinSession()
val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
try {
rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -66,7 +66,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)

View file

@ -60,7 +60,7 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
@ -103,7 +103,7 @@ class TitleAsyncFocusTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))
@ -150,7 +150,7 @@ class TitleAsyncFocusTest {
reads.incrementAndGet()
throw java.io.IOException("offline")
}
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
navigation.navigateTo(Destination.Wolfskin(route))

View file

@ -83,7 +83,7 @@ class TitleEntryBackTest {
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr)
homeModel = source
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
mockk(relaxed = true), mockk(relaxed = true))
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
val onBack = rememberWolfskinBackHandler(shell, navigation)
@ -183,7 +183,7 @@ class TitleEntryBackTest {
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
shell.enterDestination(route)
navigation.navigateTo(destination)
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))

View file

@ -59,7 +59,7 @@ class TitlePlaybackNavigationTest {
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
}
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
try {
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
@ -113,7 +113,7 @@ class TitlePlaybackNavigationTest {
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
rule.mainClock.autoAdvance = false
rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } }
fun finishHandoff() {

View file

@ -46,7 +46,7 @@ class TitleRequestSafetyTest {
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", true)
val route = titleRoute("movie:42")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }

View file

@ -163,7 +163,7 @@ class TitleThemeMusicTest {
coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE)
val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it }
val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr,
mockk(relaxed = true), playback.player).also { model = it }
mockk(relaxed = true), playback.player, trailerReturn = mockk(relaxed = true)).also { model = it }
rule.runOnIdle { navigation.navigateTo(destination) }
rule.setContent {
WolfskinTheme {

View file

@ -23,13 +23,13 @@ class TitleTrailerTest {
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
assertFalse(TitleTrailer(context).open(url))
assertFalse(TitleTrailer(context).open(url) {})
assertNull(shadowOf(context as Application).nextStartedActivity)
}
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
assertTrue(TitleTrailer(context).open(url))
assertTrue(TitleTrailer(context).open(url) {})
val launched = shadowOf(context as Application).nextStartedActivity
assertEquals(Intent.ACTION_VIEW, launched.action)
assertEquals("test.youtube", launched.`package`)
@ -40,7 +40,7 @@ class TitleTrailerTest {
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser")
assertFalse(TitleTrailer(context).open("https://trailers.example/video"))
assertFalse(TitleTrailer(context).open("https://trailers.example/video") {})
assertNull(shadowOf(context as Application).nextStartedActivity)
}

View file

@ -0,0 +1,150 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Bundle
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
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 io.mockk.every
import io.mockk.mockk
import java.util.UUID
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A saved task may carry only a short-lived, exact, unprotected trailer identity; not a generic login. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class TrailerReturnStateTest {
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 server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "never-serialize-this-token")
private val current = CurrentUser(server, user)
private val title = Destination.Wolfskin(titleRoute("Locke"))
private val identity = SavedSessionIdentity(server.id, user.id)
private fun snapshot(person: CurrentUser = current): Bundle {
val originalProcess = TrailerReturnState(clock)
originalProcess.prepare(person, title)
return Bundle().also { originalProcess.save(it, person, title) }
}
@Test fun aNewProcessCanRecoverTheExactTaskIdentityWithoutCopyingCredentials() {
val saved = snapshot()
assertFalse(saved.getString(TrailerReturnState.SAVED_STATE_KEY)!!.contains(user.accessToken!!))
elapsed += 3_000L
assertEquals(identity, TrailerReturnState(clock).restore(saved, title))
}
@Test fun freshLaunchAndCancelledHandoffHaveNoRestorationCapability() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
assertNull(state.restore(null, title))
state.prepare(current, title)
state.clear()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun theReturnWindowExpiresAtTenMinutesAndRejectsFutureTimestamps() {
for ((age, permitted) in listOf(599_999L to true, 600_000L to false, 14_400_000L to false, -1L to false)) {
elapsed = 1_000L
val saved = snapshot()
elapsed += age
assertEquals("age=$age", if (permitted) identity else null, TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun savingAnOlderHandoffDoesNotRenewItsAuthorizationTime() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
elapsed += 599_999L
val saved = Bundle().also { state.save(it, current, title) }
elapsed += 1L
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun differentAndUnknownBootsCannotRestoreATask() {
for ((initial, returning) in listOf(7 to 8, 7 to -1, -1 to 7)) {
boot = initial
val saved = snapshot()
boot = returning
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun protectedAndTokenlessPeopleCannotCreateAHandoffRecord() {
for (person in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true), user.copy(accessToken = null))) {
val saved = snapshot(current.copy(user = person))
assertNull(TrailerReturnState(clock).restore(saved, title))
}
}
@Test fun aPersonProtectionOrDestinationChangeInvalidatesTheTaskSnapshot() {
val otherServer = server.copy(id = UUID.randomUUID())
val changes = listOf(
current.copy(user = user.copy(id = UUID.randomUUID())) to title,
CurrentUser(otherServer, user.copy(serverId = otherServer.id)) to title,
current.copy(user = user.copy(pin = "URDL")) to title,
current to Destination.Wolfskin(titleRoute("another-title")),
)
for ((person, destination) in changes) {
val state = TrailerReturnState(clock)
state.prepare(current, title)
val saved = Bundle().also { state.save(it, person, destination) }
assertNull(TrailerReturnState(clock).restore(saved, destination))
}
}
@Test fun anotherRecoveredTitleAndMalformedStateDoNotAuthorizeRestoration() {
assertNull(TrailerReturnState(clock).restore(snapshot(), Destination.Wolfskin(titleRoute("another-title"))))
val malformed = Bundle().apply { putString(TrailerReturnState.SAVED_STATE_KEY, "not JSON") }
assertNull(TrailerReturnState(clock).restore(malformed, title))
}
@Test fun skippingTheColdChooserRequiresConfirmedIdentityAndIsOneUse() {
val state = TrailerReturnState(clock)
val saved = snapshot()
assertEquals(identity, state.restore(saved, title))
assertFalse(state.consumeRestoredSession(current))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
assertFalse(state.consumeRestoredSession(current))
}
@Test fun confirmationCannotDismissTheChooserForAnotherOrNowProtectedPerson() {
for (person in listOf(user.copy(id = UUID.randomUUID()), user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
val changed = current.copy(user = person)
state.confirmRestore(changed)
assertFalse(state.consumeRestoredSession(changed))
}
}
@Test fun aNormalResumeStopsLaterUnrelatedSavesFromReusingTheHandoff() {
val state = TrailerReturnState(clock)
state.prepare(current, title)
state.finishHandoff()
val saved = Bundle().also { state.save(it, current, title) }
assertNull(TrailerReturnState(clock).restore(saved, title))
}
@Test fun aRecreatedActivityResumeDoesNotDiscardItsValidatedRestoration() {
val state = TrailerReturnState(clock)
state.restore(snapshot(), title)
state.finishHandoff()
assertTrue(state.isRestoring(identity))
state.confirmRestore(current)
assertTrue(state.consumeRestoredSession(current))
}
}

View file

@ -171,7 +171,7 @@ class WolfskinRootExitTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
conductarr = ConductarrSession(MemoryCacheStore())
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
val home = HomeFixture(activity).snapshot("justin")
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,

View file

@ -18,6 +18,8 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.Dispatchers
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
@ -99,13 +101,13 @@ 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, Modifier,
mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier,
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
}
}
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService, trailerReturn = mockk(relaxed = true))
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
rule.waitUntil(5_000) {
@ -115,6 +117,16 @@ class WolfskinSetupNavigationTest {
}
}
private fun startApp() {
val startupModel = appModel()
val result = rule.activity.lifecycleScope.async(Dispatchers.Default) { startupModel.handleAppStart() }
rule.waitUntil(15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
result.isCompleted
}
runBlocking { result.await() }
}
private fun assertOwnPicker() {
awaitStage(WolfskinSetupViewModel.Stage.Picker)
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
@ -124,7 +136,7 @@ class WolfskinSetupNavigationTest {
}
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
runBlocking { appModel().handleAppStart() }
startApp()
mount()
assertOwnPicker()
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
@ -145,7 +157,7 @@ class WolfskinSetupNavigationTest {
val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"),
user.copy(id = UUID.randomUUID(), name = "Shared"))
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved)
runBlocking { appModel().handleAppStart() }
startApp()
mount()
assertOwnPicker()
saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }

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)
mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null))
}
fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) {

View file

@ -72,6 +72,7 @@ class WolfskinTitleViewModel @Inject constructor(
val conductarr: ConductarrSession,
private val repository: ServerRepository,
val themeSongPlayer: ThemeSongPlayer,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
var title by mutableStateOf<TitleState?>(null)
private set
@ -310,7 +311,13 @@ class WolfskinTitleViewModel @Inject constructor(
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
else {
themeSongPlayer.stop()
if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
val launched = TitleTrailer(context).open(action.url) {
trailerReturn.prepare(repository.current.value, navigation.backStack.lastOrNull())
}
if (!launched) {
trailerReturn.clear()
title?.toast = "No YouTube app is available to play this trailer."
}
}
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
is TitleAction.Season -> {

View file

@ -9,7 +9,7 @@ import android.net.Uri
/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */
class TitleTrailer(private val context: Context) {
/** A missing handler leaves the title in place rather than falling back to a browser. */
fun open(url: String): Boolean {
fun open(url: String, beforeLaunch: () -> Unit): Boolean {
val source = Uri.parse(url)
val video = when (source.host?.lowercase()) {
"youtu.be" -> source.pathSegments.firstOrNull()
@ -23,6 +23,7 @@ class TitleTrailer(private val context: Context) {
.firstOrNull { it.activityInfo.exported } ?: return false
intent.setPackage(handler.activityInfo.packageName)
return try {
beforeLaunch()
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {

View file

@ -0,0 +1,145 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.SavedSessionIdentity
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
/** Elapsed time includes sleep; a missing boot counter cannot authorize task restoration. */
class TrailerReturnClock @Inject constructor(@param:ApplicationContext private val context: Context) {
/** A wall-clock adjustment must not extend the return window. */
fun readElapsedMillis(): Long = SystemClock.elapsedRealtime()
/** Older or restricted systems retain the picker when same-boot evidence is unavailable. */
fun readBootCount(): Int = try {
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
} catch (_: SecurityException) {
-1
}
}
/** Only an authenticated, unprotected trailer hand-off may supply a short-lived saved-task identity. */
@Singleton
class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClock) {
private var pending: TrailerReturnRecord? = null
private var restoring: TrailerReturnRecord? = null
private var confirmed: TrailerReturnRecord? = null
/** Capture authorization before handing the activity to the external app; never capture a PIN login. */
@Synchronized
fun prepare(current: CurrentUser?, destination: Destination?) {
clear()
val route = titleDestination(destination) ?: return
if (current == null || current.user.isProtected || current.user.accessToken.isNullOrBlank()) return
val elapsed = clock.readElapsedMillis()
val boot = clock.readBootCount()
if (elapsed < 0 || boot < 0) return
pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot)
}
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
@Synchronized
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
outState.remove(SAVED_STATE_KEY)
val record = pending ?: return
if (!matches(record, current) || titleDestination(destination) != record.route || !isWithinWindow(record)) {
pending = null
return
}
outState.putString(SAVED_STATE_KEY, Json.encodeToString(record))
}
/** Saved navigation is insufficient without the matching, unexpired trailer hand-off record. */
@Synchronized
fun restore(savedState: Bundle?, destination: Destination?): SavedSessionIdentity? {
clear()
val encoded = savedState?.getString(SAVED_STATE_KEY) ?: return null
val record = try {
Json.decodeFromString<TrailerReturnRecord>(encoded)
} catch (_: SerializationException) {
return null
}
if (record.route != titleDestination(destination) || !isWithinWindow(record)) return null
val server = record.serverId.toUUIDOrNull() ?: return null
val user = record.userId.toUUIDOrNull() ?: return null
restoring = record
return SavedSessionIdentity(server, user)
}
/** The shell may skip its cold chooser only after the repository has accepted the exact identity. */
@Synchronized
fun confirmRestore(current: CurrentUser) {
val record = restoring
restoring = null
confirmed = record?.takeIf { matches(it, current) && isWithinWindow(it) }
}
@Synchronized
fun hasConfirmedRestore(): Boolean = confirmed != null
/** This one-use UI hand-off never authenticates anyone and cannot select a different person. */
@Synchronized
fun consumeRestoredSession(current: CurrentUser?): Boolean {
val record = confirmed
confirmed = null
return record != null && matches(record, current) && isWithinWindow(record)
}
/** A cancelled startup or slow lookup must not install credentials after its return window closes. */
@Synchronized
fun isRestoring(expected: SavedSessionIdentity): Boolean =
restoring?.let {
it.serverId == expected.serverId.toString() && it.userId == expected.userId.toString() && isWithinWindow(it)
} == true
/** Translucent handlers can return through onResume without onRestart; do not reuse their capture. */
@Synchronized
fun finishHandoff() {
pending = null
}
/** Normal return and explicit launches must not lend their marker to unrelated later saves. */
@Synchronized
fun clear() {
pending = null
restoring = null
confirmed = null
}
private fun matches(record: TrailerReturnRecord, current: CurrentUser?): Boolean =
current != null && !current.user.isProtected && !current.user.accessToken.isNullOrBlank() &&
current.server.id.toString() == record.serverId && current.user.id.toString() == record.userId
private fun isWithinWindow(record: TrailerReturnRecord): Boolean {
if (record.startedElapsed < 0 || record.bootCount < 0) return false
val age = clock.readElapsedMillis() - record.startedElapsed
return age >= 0 && age < RETURN_WINDOW_MILLIS && clock.readBootCount() == record.bootCount
}
private fun titleDestination(destination: Destination?): String? =
destination?.toWolfskinRoute()?.takeIf { it.startsWith("title/") }
companion object {
internal const val SAVED_STATE_KEY = "wolfskin.trailer.return"
private const val RETURN_WINDOW_MILLIS = 10L * 60L * 1000L
}
}
@Serializable
private data class TrailerReturnRecord(
val serverId: String,
val userId: String,
val route: String,
val startedElapsed: Long,
val bootCount: Int,
)

View file

@ -94,6 +94,7 @@ class WolfskinShellViewModel @Inject constructor(
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
private val trailerReturn: TrailerReturnState,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -121,10 +122,20 @@ class WolfskinShellViewModel @Inject constructor(
session.serverId = server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
val continueTrailer = trailerReturn.hasConfirmedRestore() && trailerReturn.consumeRestoredSession(repository.current.value?.takeIf {
it.server.id == server.id && it.user.id == user.id
})
session.state = ShellState(people, signedInPerson) { session.onAction(it) }.apply {
if (continueTrailer) {
pickerVisible = false
pickerLaunch = false
}
}
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
session.state?.updatePeople(savedPeople)
} else {
trailerReturn.consumeRestoredSession(null)
}
identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user)

View file

@ -449,11 +449,11 @@ only when the Advanced debug fixture switch is enabled; release excludes it.
### Household setup and sign-in recovery
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
destinations before their stock server/user selectors can render. Cold start,
restart with automatic sign-in disabled, the old drawer's user action, legacy
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
remembered server/user preference.
destinations before their stock server/user selectors can render. Cold launches
that require sign-in, protected-profile restarts, the old drawer's user action,
legacy sign-out-equivalent state, setup fallbacks and intent failures all use
Wolfskin's own picker. Entering it does not call `SwitchUserViewModel.init` or
clear the remembered server/user preference.
Setup reuses the supplied/current/recent household server, recovering the most
recently used Room server if the legacy picker erased the preference. Saved people
@ -469,6 +469,35 @@ 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.
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
same boot**. Only an exported YouTube-handler launch from an authenticated,
unprotected title creates this return record. It stores server/person IDs, the
exact title route, elapsed time and boot counter in the Activity's saved Bundle:
no token, password, PIN or new last-person preference. Saving/recreating does not
renew the deadline; missing boot evidence keeps the picker.
Before installing a saved token, restoration re-reads the profile and requires
the exact most-recent server/person, a present token, no PIN and no require-login,
then rechecks the deadline. The recommendations worker may already have restored
that same unprotected canonical identity before setup leaves Loading; continuing
it does not install credentials again. A different current person, sign-out,
removed/tokenless profile or explicit picker cannot be replaced by the return
path. PIN/require-login people remain gated. A fresh task, fresh explicit intent,
wrong route or expired/missing record follows ordinary startup policy.
After identity acceptance, a one-use confirmation lets only that person skip the
new shell's launch chooser and reach the saved title. Ordinary cold automatic
sign-in still opens the chooser. `MainActivityTrailerRestoreTest` and
`TrailerReturnStateTest` cover the process-death window and identity gates;
`MainActivityResumeTest` and `SignedInContentTest` cover live return and protection
changes while away.
Turning **Wolfskin screens** off retains the unchanged upstream
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
fixtures still seed the existing fixture picker after authentication.
@ -484,7 +513,7 @@ late reply cannot navigate back to an earlier person.
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
server and shell state, not to the user-scoped ViewModel. Switching people replaces
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
the picker stays closed on Home after the first OK. A cold start still opens it,
the picker stays closed on Home after the first OK. A fresh task still opens it,
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
shell and user-entry replacement for launch/avatar Left and Right, the initial
person, gear and back, and fast Left-Left-OK; each selection emits one person action.