diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 09dc6745..5ccfa603 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 4f2a1364..66a2b040 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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, - 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() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt new file mode 100644 index 00000000..271e47b6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt @@ -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, + 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) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index e4b86e5b..f0cff9d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -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, @@ -51,6 +50,7 @@ fun MainContent( userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, + liveSession: StateFlow, 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), - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt new file mode 100644 index 00000000..2d22ded4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt @@ -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, + 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)) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 0930129e..814b5287 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -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) diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt new file mode 100644 index 00000000..ec5143c0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -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() + private val setup = mockk(relaxed = true) + private val navigation = NavigationManager() + private val preferences = mockk>() + 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(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)) } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt new file mode 100644 index 00000000..fcf8d8e4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt @@ -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() + 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() + private val system = mockk() + private val api = mockk(relaxed = true) + private val recent = mockk() + private var recentValue: MostRecentServer = MostRecentServer.None + private val preferences = mockk>() + private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) + private var elapsed = 1_000L + private val clock = mockk().also { + every { it.readElapsedMillis() } answers { elapsed } + every { it.readBootCount() } returns 7 + } + private val restoredReturn = TrailerReturnState(clock) + private val navigation = NavigationManager() + private val setup = mockk(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(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(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() + val release = CompletableDeferred() + 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()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt new file mode 100644 index 00000000..0b7bb5fe --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -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() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token") + private val liveSession = MutableStateFlow(CurrentUser(server, user)) + + private 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() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index d8fe5695..09b9986a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -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(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) + val canonical = MutableStateFlow(null) + every { serverRepository.current } returns canonical + coEvery { serverRepository.restoreSavedSession() } coAnswers { + canonical.value = currentUser + RestoredSession.Success(currentUser) + } val release = CompletableDeferred() - coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } + val validationStarted = CompletableDeferred() + 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) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index ad3d4647..2c1d5fee 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 125a67bd..0cfd7ef2 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -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() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index 052aa05c..bfa997eb 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -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( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index 8fad3a82..edc7c984 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true)) + val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val revokedLookup = CompletableDeferred() try { val route = titleRoute("movie:42") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index 2c956245..d8057fdb 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest { runBlocking { session.authenticate() } val facade = mockk() 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() 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) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index 2162701a..e1d8c0e7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -71,7 +71,7 @@ class DiscoverTitleTimingTest { val facade = mockk() 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") } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index c412265e..98d25a86 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -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 { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 35c5cb57..6fe1b9f1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -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), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 44dfa233..1ae749c1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -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() 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(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() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 4c195596..798a3d89 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -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), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index 8a01861d..56b9cd2a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -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() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 55f81ce4..215c1582 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -60,7 +60,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(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() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(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(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(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)) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index 977af5ca..a4bb7f27 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -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(), navigation, conductarr, - mockk(relaxed = true), mockk(relaxed = true)) + mockk(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"))))) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 231ffef7..2888c113 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -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(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(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(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) rule.mainClock.autoAdvance = false rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } } fun finishHandoff() { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt index d022a821..051f2e4e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt @@ -46,7 +46,7 @@ class TitleRequestSafetyTest { runBlocking { session.authenticate() } val facade = mockk() 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) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt index fb98bd3a..5f96b327 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt @@ -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 { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt index b2b6b3e6..ac7af2db 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt @@ -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) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt new file mode 100644 index 00000000..e2d42700 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt @@ -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().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)) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt index 6e6c42ed..bcc17544 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt @@ -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, diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index 046168fa..f647b914 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -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() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt index 879404b4..b01e145d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver) + mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null)) } fun awaitHeading(resource: Int) { rule.waitUntil(5_000) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 0bd7253d..e497cc5e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -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(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 -> { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt index 7cf330d3..a46c6a0a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt @@ -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) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt new file mode 100644 index 00000000..868871ee --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt @@ -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(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, +) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 20990590..eb50cddb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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 = 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) diff --git a/docker/README.md b/docker/README.md index c57ae04f..4ff9ec89 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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.