Restore bounded trailer tasks after process death
This commit is contained in:
parent
b6665d016b
commit
1bdae30e11
32 changed files with 1082 additions and 247 deletions
|
|
@ -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, resuming = savedInstanceState != null)
|
||||
viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
|
|
@ -310,6 +300,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.d("onResume")
|
||||
trailerReturn.finishHandoff()
|
||||
viewModel.appResume()
|
||||
lifecycleScope.launchDefault {
|
||||
screensaverService.pulse()
|
||||
|
|
@ -319,6 +310,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onRestart() {
|
||||
super.onRestart()
|
||||
Timber.d("onRestart")
|
||||
trailerReturn.clear()
|
||||
viewModel.appStart(null, resuming = true)
|
||||
if (!playExternalViewModel.launched.value) {
|
||||
// If restarting during playback that is not external, go back a page
|
||||
|
|
@ -375,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) {
|
||||
|
|
@ -425,187 +418,6 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
val serverRepository: ServerRepository,
|
||||
private val setupNavigationManager: SetupNavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
private val backdropService: BackdropService,
|
||||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
private val intentService: IntentService,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun appStart(intent: Intent?, resuming: Boolean = false) {
|
||||
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(resuming)
|
||||
} 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(resuming: Boolean = false) {
|
||||
val signInAutomatically = preferences.data.first().signInAutomatically
|
||||
val currentUser = serverRepository.current.value
|
||||
// A picker may be an explicit switch or expired login, even while a user remains cached.
|
||||
val shown = if (resuming && !signInAutomatically) {
|
||||
(setupNavigationManager.backStack.lastOrNull() as? SetupDestination.AppContent)?.current
|
||||
} else null
|
||||
val canContinue = currentUser != null && shown != null &&
|
||||
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
|
||||
if ((signInAutomatically || canContinue) && currentUser != null && !currentUser.user.isProtected) {
|
||||
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() =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.IntentResult
|
||||
import com.github.damontecres.wholphin.services.IntentService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.tv.TrailerReturnState
|
||||
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
val serverRepository: ServerRepository,
|
||||
private val setupNavigationManager: SetupNavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
private val backdropService: BackdropService,
|
||||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
private val intentService: IntentService,
|
||||
private val trailerReturn: TrailerReturnState,
|
||||
) : ViewModel() {
|
||||
private val mutex = Mutex()
|
||||
private var startupJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) {
|
||||
if (!resuming) trailerReturn.clear()
|
||||
startupJob?.cancel()
|
||||
startupJob = viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
try {
|
||||
handleAppUpgrade()
|
||||
} catch (ex: Exception) {
|
||||
if (ex is kotlinx.coroutines.CancellationException) throw ex
|
||||
Timber.e(ex, "Error during app upgrade!")
|
||||
showToast(
|
||||
context,
|
||||
"Error during app upgrade: ${ex.localizedMessage}",
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// A saved trailer task must not replay its original deep link or authenticate its old intent's person.
|
||||
val restoringTrailer = resuming && savedState?.containsKey(TrailerReturnState.SAVED_STATE_KEY) == true
|
||||
val result = if (restoringTrailer) false else handleIntent(intent)
|
||||
if (result) {
|
||||
Timber.i("appStart was handled by intent")
|
||||
return@withLock
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
if (ex is kotlinx.coroutines.CancellationException) throw ex
|
||||
Timber.e(ex, "Error parsing intent")
|
||||
}
|
||||
|
||||
try {
|
||||
handleAppStart(resuming, savedState)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is kotlinx.coroutines.CancellationException) throw ex
|
||||
Timber.e(ex, "Error during appStart")
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
// Create the mediaCodecCapabilitiesTest if needed
|
||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||
}
|
||||
}
|
||||
|
||||
fun appResume() {
|
||||
viewModelScope.launchDefault {
|
||||
mutex.withLock {
|
||||
val expected = serverRepository.current.value
|
||||
try {
|
||||
Timber.v("Updating userDto")
|
||||
serverRepository.updateUserDto()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error during appResume")
|
||||
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an [Intent], navigating if needed
|
||||
*
|
||||
* @return true if the intent was handled, false if normal app start should proceed
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleIntent(intent: Intent?): Boolean {
|
||||
val result = intent?.let { intentService.parseIntent(intent) }
|
||||
when (result) {
|
||||
is IntentResult.Error -> {
|
||||
val current = serverRepository.current.value
|
||||
val destination =
|
||||
if (current != null) {
|
||||
SetupDestination.UserList(current.server)
|
||||
} else {
|
||||
SetupDestination.ServerList
|
||||
}
|
||||
setupNavigationManager.navigateTo(destination)
|
||||
Timber.e("Error parsing intent: %s", result.message)
|
||||
showToast(context, result.message)
|
||||
return true
|
||||
}
|
||||
|
||||
is IntentResult.Target -> {
|
||||
val current = serverRepository.current.value
|
||||
if (current != null) {
|
||||
Timber.i("Received valid intent, switching to AppContent")
|
||||
|
||||
if (result.addHomeToBackStack) {
|
||||
navigationManager.reloadHome()
|
||||
} else {
|
||||
navigationManager.backStack.clear()
|
||||
}
|
||||
navigationManager.backStack.addAll(result.destinations)
|
||||
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(current),
|
||||
)
|
||||
} else {
|
||||
// This should never happen, but just reset app state if it does
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
Timber.e("Error parsing intent, no user is active")
|
||||
showToast(context, "An error occurred parsing the intent")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
IntentResult.NoOp,
|
||||
null,
|
||||
-> {
|
||||
// Not handled
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAppUpgrade() {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle starting the app including auto sign in and navigating to the right starting page
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun handleAppStart(resuming: Boolean = false, savedState: Bundle? = null) {
|
||||
val signInAutomatically = preferences.data.first().signInAutomatically
|
||||
val currentUser = serverRepository.current.value
|
||||
val owner = setupNavigationManager.backStack.lastOrNull()
|
||||
val expected = if (resuming && savedState != null) {
|
||||
trailerReturn.restore(savedState, navigationManager.backStack.lastOrNull())
|
||||
} else {
|
||||
trailerReturn.clear()
|
||||
null
|
||||
}
|
||||
val shown = (owner as? SetupDestination.AppContent)?.current
|
||||
val canContinue = currentUser != null && shown != null &&
|
||||
currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id
|
||||
if (expected != null) {
|
||||
val restored = when {
|
||||
currentUser == null && owner == SetupDestination.Loading ->
|
||||
serverRepository.restoreSavedSession(expected) {
|
||||
trailerReturn.isRestoring(expected) && serverRepository.current.value == null &&
|
||||
setupNavigationManager.backStack.lastOrNull() === owner
|
||||
}
|
||||
currentUser != null && (canContinue || owner == SetupDestination.Loading) && !currentUser.user.isProtected &&
|
||||
currentUser.server.id == expected.serverId && currentUser.user.id == expected.userId &&
|
||||
serverRepository.isSavedIdentity(expected) -> RestoredSession.Success(currentUser)
|
||||
else -> serverRepository.getMostRecentServer()
|
||||
}
|
||||
finishRestore(restored, owner, fromTrailer = true, revalidate = currentUser == null)
|
||||
return
|
||||
}
|
||||
// A picker may be an explicit switch or expired login, even while a user remains cached.
|
||||
if ((signInAutomatically || (resuming && canContinue)) && currentUser != null && !currentUser.user.isProtected) {
|
||||
withContext(WholphinDispatchers.Main.immediate) {
|
||||
if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) {
|
||||
setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val restored = if (signInAutomatically) serverRepository.restoreSavedSession() else serverRepository.getMostRecentServer()
|
||||
finishRestore(restored, owner, fromTrailer = false, revalidate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishRestore(
|
||||
restored: RestoredSession,
|
||||
owner: SetupDestination?,
|
||||
fromTrailer: Boolean,
|
||||
revalidate: Boolean,
|
||||
) {
|
||||
val shown = withContext(WholphinDispatchers.Main.immediate) {
|
||||
if (setupNavigationManager.backStack.lastOrNull() !== owner) return@withContext false
|
||||
if (restored is RestoredSession.Success && serverRepository.current.value !== restored.currentUser) return@withContext false
|
||||
if (fromTrailer && restored is RestoredSession.Success) trailerReturn.confirmRestore(restored.currentUser)
|
||||
else trailerReturn.clear()
|
||||
setupNavigationManager.navigateTo(restored.destination)
|
||||
true
|
||||
}
|
||||
if (shown && revalidate && restored is RestoredSession.Success) {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
serverRepository.revalidateSession(restored.currentUser)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
Timber.w(exception, "Saved launch session revalidation failed")
|
||||
recoverWolfskinSession(context, exception, restored.currentUser, serverRepository, setupNavigationManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class MainActivityResumeTest {
|
|||
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))
|
||||
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
|
||||
@Before fun configure() {
|
||||
WholphinDispatchers.configure(dispatcher)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room.Room
|
||||
import com.github.damontecres.wholphin.data.*
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.services.*
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.*
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.json.JSONObject
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.*
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.justinvisser.wolfskin.tv.update.FakeUpdateController
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** New repository and shell instances reproduce process death, not merely activity recreation. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class MainActivityTrailerRestoreTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", "12.1.0")
|
||||
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-token")
|
||||
private val users = mockk<UserApi>()
|
||||
private val system = mockk<SystemApi>()
|
||||
private val api = mockk<ApiClient>(relaxed = true)
|
||||
private val recent = mockk<MostRecentServerProvider>()
|
||||
private var recentValue: MostRecentServer = MostRecentServer.None
|
||||
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||
private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false })
|
||||
private var elapsed = 1_000L
|
||||
private val clock = mockk<TrailerReturnClock>().also {
|
||||
every { it.readElapsedMillis() } answers { elapsed }
|
||||
every { it.readBootCount() } returns 7
|
||||
}
|
||||
private val restoredReturn = TrailerReturnState(clock)
|
||||
private val navigation = NavigationManager()
|
||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
private val title = Destination.Wolfskin(titleRoute("Locke"))
|
||||
private val database by lazy {
|
||||
Room.inMemoryDatabaseBuilder(rule.activity, AppDatabase::class.java).allowMainThreadQueries().build()
|
||||
}
|
||||
private val dao by lazy { spyk(database.serverDao()) }
|
||||
private val repository by lazy { ServerRepository(rule.activity, mockk(), dao, api, Dispatchers.Unconfined, recent) }
|
||||
private val intents = mockk<IntentService>(relaxed = true)
|
||||
private val model by lazy { MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
||||
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intents, restoredReturn) }
|
||||
@Volatile private var destination: SetupDestination = SetupDestination.Loading
|
||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||
private var shellModel: WolfskinShellViewModel? = null
|
||||
private var previousFixtures = false
|
||||
private var previousScreens = true
|
||||
|
||||
@Before fun savedIdentityButNoLiveSession() {
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
ConductarrPreference.updateFixtures(true)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
dao.addOrUpdateServer(server)
|
||||
user = dao.addOrUpdateUser(user)
|
||||
recentValue = MostRecentServer.from(CurrentUser(server, user))
|
||||
every { recent.get() } answers { recentValue }
|
||||
every { api.userApi } returns users
|
||||
every { api.systemApi } returns system
|
||||
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = user.id, name = "Justin",
|
||||
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
|
||||
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(serverName = server.name, version = server.version))
|
||||
every { preferences.data } returns automatic
|
||||
every { setup.backStack } answers { mutableListOf(destination) }
|
||||
every { setup.navigateTo(any()) } answers { destination = firstArg() }
|
||||
navigation.backStack.add(title)
|
||||
val handler = ResolveInfo().apply {
|
||||
activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true }
|
||||
}
|
||||
shadowOf(rule.activity.packageManager).addResolveInfoForIntent(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), handler)
|
||||
assertNull(repository.current.value)
|
||||
}
|
||||
|
||||
@After fun closeSessions() {
|
||||
val jobs = listOfNotNull(model.viewModelScope.coroutineContext[Job], shellModel?.viewModelScope?.coroutineContext?.get(Job))
|
||||
jobs.forEach { it.cancel() }
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
runBlocking { jobs.forEach { it.join() } }
|
||||
conductarr.close()
|
||||
database.close()
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
}
|
||||
|
||||
private fun snapshot(): Bundle {
|
||||
val oldProcess = TrailerReturnState(clock)
|
||||
val alreadySignedIn = CurrentUser(server, user)
|
||||
check(TitleTrailer(rule.activity).open("https://www.youtube.com/watch?v=ab_-defghij") {
|
||||
oldProcess.prepare(alreadySignedIn, title)
|
||||
})
|
||||
return Bundle().also { oldProcess.save(it, alreadySignedIn, title) }
|
||||
}
|
||||
|
||||
private fun restore(saved: Bundle? = snapshot(), resuming: Boolean = true) {
|
||||
val result = model.viewModelScope.async(Dispatchers.Default) { model.handleAppStart(resuming = resuming, savedState = saved) }
|
||||
rule.waitUntil(15_000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
result.isCompleted
|
||||
}
|
||||
runBlocking { result.await() }
|
||||
}
|
||||
|
||||
private fun assertGated() {
|
||||
assertEquals(SetupDestination.UserList(server), destination)
|
||||
assertNull(repository.current.value)
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
|
||||
private fun loadFreshShell(): ShellState {
|
||||
val session = WolfskinSession()
|
||||
val fresh = WolfskinShellViewModel(rule.activity, session, repository, navigation,
|
||||
mockk(relaxed = true), mockk(relaxed = true), mockk<LibraryReadFacade>(relaxed = true),
|
||||
WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), FakeUpdateController(), restoredReturn)
|
||||
shellModel = fresh
|
||||
runBlocking { fresh.load(server, requireNotNull(repository.current.value).user) }
|
||||
return requireNotNull(session.state)
|
||||
}
|
||||
|
||||
private fun render(shell: ShellState) {
|
||||
shell.enterDestination(titleRoute("Locke"))
|
||||
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
val scene = TitleState(fixture.title("Locke", shell.person.key), shell.person) {}
|
||||
rule.setContent { WolfskinTheme { TitleSurface(scene, shell) } }
|
||||
}
|
||||
|
||||
private fun sameTitleAfterProcessDeath(automaticSignIn: Boolean, capture: String = automaticSignIn.toString()) {
|
||||
automatic.value = automatic.value.update { signInAutomatically = automaticSignIn }
|
||||
restore()
|
||||
val current = requireNotNull(repository.current.value)
|
||||
assertEquals(SetupDestination.AppContent(current), destination)
|
||||
assertEquals(server.id, current.server.id)
|
||||
assertEquals(user.id, current.user.id)
|
||||
assertEquals(title, navigation.backStack.last())
|
||||
val shell = loadFreshShell()
|
||||
render(shell)
|
||||
rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke")
|
||||
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "process-restored-title-$capture.png"))
|
||||
}
|
||||
|
||||
@Test fun processDeathReturnsToTheSamePersonAndTitleWithAutomaticSignInOff() = sameTitleAfterProcessDeath(false)
|
||||
@Test fun processDeathDoesNotReopenTheFreshShellChooserWithAutomaticSignInOn() = sameTitleAfterProcessDeath(true)
|
||||
|
||||
@Test fun protectionAddedWhileTheProcessWasDeadStillRequiresUnlocking() {
|
||||
val saved = snapshot()
|
||||
for (protected in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
|
||||
destination = SetupDestination.Loading
|
||||
dao.updateUser(protected)
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aDifferentMostRecentPersonCannotReplaceTheTrailerIdentityEvenWithAutomaticSignInOn() {
|
||||
val saved = snapshot()
|
||||
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
|
||||
recentValue = MostRecentServer.from(CurrentUser(server, other))
|
||||
for (enabled in listOf(false, true)) {
|
||||
destination = SetupDestination.Loading
|
||||
automatic.value = automatic.value.update { signInAutomatically = enabled }
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anExistingPickerCannotBeBypassedByATrailerSnapshot() {
|
||||
destination = SetupDestination.UserList(server)
|
||||
automatic.value = automatic.value.update { signInAutomatically = true }
|
||||
restore()
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun missingTokenCannotBeRecoveredFromTheSavedTask() {
|
||||
val saved = snapshot()
|
||||
dao.updateUser(user.copy(accessToken = null))
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun removedPeopleAreNotRecreatedByTheSavedTask() {
|
||||
val saved = snapshot()
|
||||
dao.deleteUser(server.id, user.id)
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun aFreshLaunchCannotUseAPreviousTrailerSnapshot() {
|
||||
restore(snapshot(), resuming = false)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun aFreshColdLaunchWithoutSavedTaskStateKeepsThePicker() {
|
||||
restore(saved = null)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun aReturnHoursLaterCannotRestoreThePerson() {
|
||||
val saved = snapshot()
|
||||
elapsed += 4L * 60L * 60L * 1000L
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun aDifferentRestoredDestinationCannotReuseTheIdentityRecord() {
|
||||
val saved = snapshot()
|
||||
navigation.backStack[navigation.backStack.lastIndex] = Destination.Wolfskin(titleRoute("another-title"))
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun automaticSignInWithoutATrailerRecordStillShowsTheColdChooser() {
|
||||
automatic.value = automatic.value.update { signInAutomatically = true }
|
||||
restore(saved = null)
|
||||
render(loadFreshShell())
|
||||
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||
rule.onNodeWithTag("title-heading").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun expiryDuringProfileLookupCannotInstallTheSavedToken() {
|
||||
val saved = snapshot()
|
||||
every { dao.getServer(server.id) } answers {
|
||||
elapsed += 600_000L
|
||||
callOriginal()
|
||||
}
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun restoringATrailerTaskDoesNotReplayItsOriginalLaunchIntent() {
|
||||
coEvery { intents.parseIntent(any()) } returns IntentResult.Error("Old launch target expired")
|
||||
model.appStart(Intent("old.launch.target"), resuming = true, savedState = snapshot())
|
||||
rule.waitUntil(15_000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
destination != SetupDestination.Loading
|
||||
}
|
||||
assertEquals(SetupDestination.AppContent(requireNotNull(repository.current.value)), destination)
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
assertEquals(title, navigation.backStack.last())
|
||||
}
|
||||
|
||||
@Test fun aNewExplicitLaunchCancelsAnOlderPendingTaskRestore() {
|
||||
val started = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
var firstRead = true
|
||||
every { preferences.data } returns kotlinx.coroutines.flow.flow {
|
||||
if (firstRead) {
|
||||
firstRead = false
|
||||
started.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
emit(automatic.value)
|
||||
}
|
||||
model.appStart(null, resuming = true, savedState = snapshot())
|
||||
rule.waitUntil(15_000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
started.isCompleted
|
||||
}
|
||||
model.appStart(null)
|
||||
release.complete(Unit)
|
||||
rule.waitUntil(15_000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
destination != SetupDestination.Loading
|
||||
}
|
||||
assertGated()
|
||||
}
|
||||
|
||||
private fun authenticateSavedPerson(person: JellyfinUser = user) {
|
||||
recentValue = MostRecentServer.from(CurrentUser(server, person))
|
||||
coEvery { recent.save(any()) } coAnswers {
|
||||
recentValue = MostRecentServer.from(firstArg())
|
||||
}
|
||||
coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = person.id, name = person.name,
|
||||
hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false))
|
||||
runBlocking { check(repository.restoreLastSession() is RestoredSession.Success) }
|
||||
clearMocks(api, answers = false, recordedCalls = true)
|
||||
}
|
||||
|
||||
@Test fun aRecommendationsWorkerRestoredPersonReturnsToTheSameTitleWithoutSigningInAgain() {
|
||||
authenticateSavedPerson()
|
||||
sameTitleAfterProcessDeath(false, capture = "worker")
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
|
||||
@Test fun aWorkerRestoredPersonWithoutTrailerMetadataCannotSkipTheColdPicker() {
|
||||
authenticateSavedPerson()
|
||||
restore(saved = null)
|
||||
assertEquals(SetupDestination.UserList(server), destination)
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
|
||||
@Test fun aDifferentCurrentWorkerIdentityCannotBeReplacedByTheCapturedPerson() {
|
||||
val saved = snapshot()
|
||||
val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token"))
|
||||
authenticateSavedPerson(other)
|
||||
recentValue = MostRecentServer.from(CurrentUser(server, user))
|
||||
restore(saved)
|
||||
assertEquals(SetupDestination.UserList(server), destination)
|
||||
assertEquals(other.id, repository.current.value!!.user.id)
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
|
||||
@Test fun aProtectedCurrentWorkerIdentityStillRequiresUnlocking() {
|
||||
authenticateSavedPerson()
|
||||
val saved = snapshot()
|
||||
for ((pin, login) in listOf("URDL" to false, null to true)) {
|
||||
destination = SetupDestination.Loading
|
||||
runBlocking { repository.updateUserAuth(user, pin, login) }
|
||||
restore(saved)
|
||||
assertEquals(SetupDestination.UserList(server), destination)
|
||||
assertTrue(repository.current.value!!.user.isProtected)
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun clearingTheSavedLoginInvalidatesAnOtherwiseEligibleTaskSnapshot() {
|
||||
val saved = snapshot()
|
||||
recentValue = MostRecentServer.Server(server.id, server.url)
|
||||
restore(saved)
|
||||
assertGated()
|
||||
}
|
||||
|
||||
@Test fun aCachedPersonCannotDismissAnExplicitPickerWithSavedTrailerMetadata() {
|
||||
authenticateSavedPerson()
|
||||
val saved = snapshot()
|
||||
destination = SetupDestination.UserList(server)
|
||||
automatic.value = automatic.value.update { signInAutomatically = true }
|
||||
restore(saved)
|
||||
assertEquals(SetupDestination.UserList(server), destination)
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
verify(exactly = 0) { api.update(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ class SignedInContentTest {
|
|||
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))
|
||||
if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {})
|
||||
}
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ class TestMainActivityViewModel {
|
|||
backdropService,
|
||||
appUpgradeHandler,
|
||||
intentService,
|
||||
trailerReturn = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
private val serverId = UUID.randomUUID()
|
||||
|
|
@ -88,6 +89,7 @@ class TestMainActivityViewModel {
|
|||
@Before
|
||||
fun setUp() {
|
||||
WholphinDispatchers.configure(testDispatcher)
|
||||
every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading)
|
||||
every { appUpgradeHandler.needUpgrade() } returns false
|
||||
every { appUpgradeHandler.copySubfont(any()) } returns Unit
|
||||
mockkStatic(Toast::class)
|
||||
|
|
@ -167,14 +169,22 @@ class TestMainActivityViewModel {
|
|||
@Test
|
||||
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
|
||||
setupPreferences { signInAutomatically = true }
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
|
||||
val canonical = MutableStateFlow<CurrentUser?>(null)
|
||||
every { serverRepository.current } returns canonical
|
||||
coEvery { serverRepository.restoreSavedSession() } coAnswers {
|
||||
canonical.value = currentUser
|
||||
RestoredSession.Success(currentUser)
|
||||
}
|
||||
val release = CompletableDeferred<Unit>()
|
||||
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
|
||||
val validationStarted = CompletableDeferred<Unit>()
|
||||
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers {
|
||||
validationStarted.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
viewModel.handleAppStart()
|
||||
runCurrent()
|
||||
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
|
||||
assertFalse(release.isCompleted)
|
||||
Assert.assertTrue(validationStarted.isCompleted)
|
||||
release.complete(Unit)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest {
|
|||
runBlocking { session.authenticate() }
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.findHeld(any(), any()) } returns null
|
||||
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val revokedLookup = CompletableDeferred<java.util.UUID?>()
|
||||
try {
|
||||
val route = titleRoute("movie:42")
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest {
|
|||
runBlocking { session.authenticate() }
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.findHeld(any(), any()) } returns null
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val person = ShellPerson("shared", "Shared", "S", false)
|
||||
val route = titleRoute("tv:44")
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||
|
|
@ -112,7 +112,7 @@ class DiscoverRefreshMotionTest {
|
|||
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
|
||||
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
|
||||
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||
NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val person = ShellPerson("justin", "Justin", "J", true)
|
||||
val route = titleRoute("movie-the-amazing-spider-man-2012")
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||
|
|
@ -166,7 +166,7 @@ class DiscoverRefreshMotionTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(any(), any()) } returns null
|
||||
coEvery { facade.read(any(), any(), any()) } returns held
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val person = ShellPerson("shared", "Shared", "S", false)
|
||||
val route = titleRoute(id.toString())
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class DiscoverTitleTimingTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.findHeld(any(), any()) } returns null
|
||||
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
|
|
@ -220,7 +220,7 @@ class PersonSwitchThreadingTest {
|
|||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
|
||||
val load = launch { model.load(server, user) }
|
||||
try {
|
||||
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||
|
|
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true))
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class TitleAsyncFocusTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(id, any()) } returns null
|
||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val person = ShellPerson("justin", "Justin", "J", false)
|
||||
val route = titleRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
|
|
@ -103,7 +103,7 @@ class TitleAsyncFocusTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readPersonSaved(id) } returns null
|
||||
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val route = personRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
|
|
@ -150,7 +150,7 @@ class TitleAsyncFocusTest {
|
|||
reads.incrementAndGet()
|
||||
throw java.io.IOException("offline")
|
||||
}
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
val route = titleRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class TitleEntryBackTest {
|
|||
val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr)
|
||||
homeModel = source
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr,
|
||||
mockk(relaxed = true), mockk(relaxed = true))
|
||||
mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
|
||||
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
||||
|
|
@ -183,7 +183,7 @@ class TitleEntryBackTest {
|
|||
}
|
||||
conductarr = ConductarrSession(MemoryCacheStore())
|
||||
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
|
||||
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
shell.enterDestination(route)
|
||||
navigation.navigateTo(destination)
|
||||
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class TitlePlaybackNavigationTest {
|
|||
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
|
||||
}
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
|
||||
try {
|
||||
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
|
||||
|
|
@ -109,7 +109,7 @@ class TitlePlaybackNavigationTest {
|
|||
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
|
||||
val session = ConductarrSession(MemoryCacheStore())
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
try {
|
||||
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
|
||||
advanceUntilIdle()
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class TitleRequestSafetyTest {
|
|||
runBlocking { session.authenticate() }
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.findHeld(any(), any()) } returns null
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true))
|
||||
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true))
|
||||
val person = ShellPerson("shared", "Shared", "S", true)
|
||||
val route = titleRoute("movie:42")
|
||||
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
|
||||
|
|
|
|||
|
|
@ -162,7 +162,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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.SavedSessionIdentity
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A saved task may carry only a short-lived, exact, unprotected trailer identity; not a generic login. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class TrailerReturnStateTest {
|
||||
private var elapsed = 1_000L
|
||||
private var boot = 7
|
||||
private val clock = mockk<TrailerReturnClock>().also {
|
||||
every { it.readElapsedMillis() } answers { elapsed }
|
||||
every { it.readBootCount() } answers { boot }
|
||||
}
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "never-serialize-this-token")
|
||||
private val current = CurrentUser(server, user)
|
||||
private val title = Destination.Wolfskin(titleRoute("Locke"))
|
||||
private val identity = SavedSessionIdentity(server.id, user.id)
|
||||
|
||||
private fun snapshot(person: CurrentUser = current): Bundle {
|
||||
val originalProcess = TrailerReturnState(clock)
|
||||
originalProcess.prepare(person, title)
|
||||
return Bundle().also { originalProcess.save(it, person, title) }
|
||||
}
|
||||
|
||||
@Test fun aNewProcessCanRecoverTheExactTaskIdentityWithoutCopyingCredentials() {
|
||||
val saved = snapshot()
|
||||
assertFalse(saved.getString(TrailerReturnState.SAVED_STATE_KEY)!!.contains(user.accessToken!!))
|
||||
elapsed += 3_000L
|
||||
assertEquals(identity, TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
|
||||
@Test fun freshLaunchAndCancelledHandoffHaveNoRestorationCapability() {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.prepare(current, title)
|
||||
assertNull(state.restore(null, title))
|
||||
state.prepare(current, title)
|
||||
state.clear()
|
||||
val saved = Bundle().also { state.save(it, current, title) }
|
||||
assertNull(TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
|
||||
@Test fun theReturnWindowExpiresAtTenMinutesAndRejectsFutureTimestamps() {
|
||||
for ((age, permitted) in listOf(599_999L to true, 600_000L to false, 14_400_000L to false, -1L to false)) {
|
||||
elapsed = 1_000L
|
||||
val saved = snapshot()
|
||||
elapsed += age
|
||||
assertEquals("age=$age", if (permitted) identity else null, TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun savingAnOlderHandoffDoesNotRenewItsAuthorizationTime() {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.prepare(current, title)
|
||||
elapsed += 599_999L
|
||||
val saved = Bundle().also { state.save(it, current, title) }
|
||||
elapsed += 1L
|
||||
assertNull(TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
|
||||
@Test fun differentAndUnknownBootsCannotRestoreATask() {
|
||||
for ((initial, returning) in listOf(7 to 8, 7 to -1, -1 to 7)) {
|
||||
boot = initial
|
||||
val saved = snapshot()
|
||||
boot = returning
|
||||
assertNull(TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun protectedAndTokenlessPeopleCannotCreateAHandoffRecord() {
|
||||
for (person in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true), user.copy(accessToken = null))) {
|
||||
val saved = snapshot(current.copy(user = person))
|
||||
assertNull(TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aPersonProtectionOrDestinationChangeInvalidatesTheTaskSnapshot() {
|
||||
val otherServer = server.copy(id = UUID.randomUUID())
|
||||
val changes = listOf(
|
||||
current.copy(user = user.copy(id = UUID.randomUUID())) to title,
|
||||
CurrentUser(otherServer, user.copy(serverId = otherServer.id)) to title,
|
||||
current.copy(user = user.copy(pin = "URDL")) to title,
|
||||
current to Destination.Wolfskin(titleRoute("another-title")),
|
||||
)
|
||||
for ((person, destination) in changes) {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.prepare(current, title)
|
||||
val saved = Bundle().also { state.save(it, person, destination) }
|
||||
assertNull(TrailerReturnState(clock).restore(saved, destination))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anotherRecoveredTitleAndMalformedStateDoNotAuthorizeRestoration() {
|
||||
assertNull(TrailerReturnState(clock).restore(snapshot(), Destination.Wolfskin(titleRoute("another-title"))))
|
||||
val malformed = Bundle().apply { putString(TrailerReturnState.SAVED_STATE_KEY, "not JSON") }
|
||||
assertNull(TrailerReturnState(clock).restore(malformed, title))
|
||||
}
|
||||
|
||||
@Test fun skippingTheColdChooserRequiresConfirmedIdentityAndIsOneUse() {
|
||||
val state = TrailerReturnState(clock)
|
||||
val saved = snapshot()
|
||||
assertEquals(identity, state.restore(saved, title))
|
||||
assertFalse(state.consumeRestoredSession(current))
|
||||
state.confirmRestore(current)
|
||||
assertTrue(state.consumeRestoredSession(current))
|
||||
assertFalse(state.consumeRestoredSession(current))
|
||||
}
|
||||
|
||||
@Test fun confirmationCannotDismissTheChooserForAnotherOrNowProtectedPerson() {
|
||||
for (person in listOf(user.copy(id = UUID.randomUUID()), user.copy(pin = "URDL"), user.copy(requireLogin = true))) {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.restore(snapshot(), title)
|
||||
val changed = current.copy(user = person)
|
||||
state.confirmRestore(changed)
|
||||
assertFalse(state.consumeRestoredSession(changed))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aNormalResumeStopsLaterUnrelatedSavesFromReusingTheHandoff() {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.prepare(current, title)
|
||||
state.finishHandoff()
|
||||
val saved = Bundle().also { state.save(it, current, title) }
|
||||
assertNull(TrailerReturnState(clock).restore(saved, title))
|
||||
}
|
||||
|
||||
@Test fun aRecreatedActivityResumeDoesNotDiscardItsValidatedRestoration() {
|
||||
val state = TrailerReturnState(clock)
|
||||
state.restore(snapshot(), title)
|
||||
state.finishHandoff()
|
||||
assertTrue(state.isRestoring(identity))
|
||||
state.confirmRestore(current)
|
||||
assertTrue(state.consumeRestoredSession(current))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -105,7 +107,7 @@ class WolfskinSetupNavigationTest {
|
|||
}
|
||||
|
||||
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() }
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
val conductarr: ConductarrSession,
|
||||
private val repository: ServerRepository,
|
||||
val themeSongPlayer: ThemeSongPlayer,
|
||||
private val trailerReturn: TrailerReturnState,
|
||||
) : ViewModel() {
|
||||
var title by mutableStateOf<TitleState?>(null)
|
||||
private set
|
||||
|
|
@ -272,7 +273,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 -> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.SavedSessionIdentity
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
||||
/** Elapsed time includes sleep; a missing boot counter cannot authorize task restoration. */
|
||||
class TrailerReturnClock @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||
/** A wall-clock adjustment must not extend the return window. */
|
||||
fun readElapsedMillis(): Long = SystemClock.elapsedRealtime()
|
||||
|
||||
/** Older or restricted systems retain the picker when same-boot evidence is unavailable. */
|
||||
fun readBootCount(): Int = try {
|
||||
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1)
|
||||
} catch (_: SecurityException) {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an authenticated, unprotected trailer hand-off may supply a short-lived saved-task identity. */
|
||||
@Singleton
|
||||
class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClock) {
|
||||
private var pending: TrailerReturnRecord? = null
|
||||
private var restoring: TrailerReturnRecord? = null
|
||||
private var confirmed: TrailerReturnRecord? = null
|
||||
|
||||
/** Capture authorization before handing the activity to the external app; never capture a PIN login. */
|
||||
@Synchronized
|
||||
fun prepare(current: CurrentUser?, destination: Destination?) {
|
||||
clear()
|
||||
val route = titleDestination(destination) ?: return
|
||||
if (current == null || current.user.isProtected || current.user.accessToken.isNullOrBlank()) return
|
||||
val elapsed = clock.readElapsedMillis()
|
||||
val boot = clock.readBootCount()
|
||||
if (elapsed < 0 || boot < 0) return
|
||||
pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot)
|
||||
}
|
||||
|
||||
/** A task snapshot cannot carry an old identity after a person/protection/destination change. */
|
||||
@Synchronized
|
||||
fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) {
|
||||
outState.remove(SAVED_STATE_KEY)
|
||||
val record = pending ?: return
|
||||
if (!matches(record, current) || titleDestination(destination) != record.route || !isWithinWindow(record)) {
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
outState.putString(SAVED_STATE_KEY, Json.encodeToString(record))
|
||||
}
|
||||
|
||||
/** Saved navigation is insufficient without the matching, unexpired trailer hand-off record. */
|
||||
@Synchronized
|
||||
fun restore(savedState: Bundle?, destination: Destination?): SavedSessionIdentity? {
|
||||
clear()
|
||||
val encoded = savedState?.getString(SAVED_STATE_KEY) ?: return null
|
||||
val record = try {
|
||||
Json.decodeFromString<TrailerReturnRecord>(encoded)
|
||||
} catch (_: SerializationException) {
|
||||
return null
|
||||
}
|
||||
if (record.route != titleDestination(destination) || !isWithinWindow(record)) return null
|
||||
val server = record.serverId.toUUIDOrNull() ?: return null
|
||||
val user = record.userId.toUUIDOrNull() ?: return null
|
||||
restoring = record
|
||||
return SavedSessionIdentity(server, user)
|
||||
}
|
||||
|
||||
/** The shell may skip its cold chooser only after the repository has accepted the exact identity. */
|
||||
@Synchronized
|
||||
fun confirmRestore(current: CurrentUser) {
|
||||
val record = restoring
|
||||
restoring = null
|
||||
confirmed = record?.takeIf { matches(it, current) && isWithinWindow(it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasConfirmedRestore(): Boolean = confirmed != null
|
||||
|
||||
/** This one-use UI hand-off never authenticates anyone and cannot select a different person. */
|
||||
@Synchronized
|
||||
fun consumeRestoredSession(current: CurrentUser?): Boolean {
|
||||
val record = confirmed
|
||||
confirmed = null
|
||||
return record != null && matches(record, current) && isWithinWindow(record)
|
||||
}
|
||||
|
||||
/** A cancelled startup or slow lookup must not install credentials after its return window closes. */
|
||||
@Synchronized
|
||||
fun isRestoring(expected: SavedSessionIdentity): Boolean =
|
||||
restoring?.let {
|
||||
it.serverId == expected.serverId.toString() && it.userId == expected.userId.toString() && isWithinWindow(it)
|
||||
} == true
|
||||
|
||||
/** Translucent handlers can return through onResume without onRestart; do not reuse their capture. */
|
||||
@Synchronized
|
||||
fun finishHandoff() {
|
||||
pending = null
|
||||
}
|
||||
|
||||
/** Normal return and explicit launches must not lend their marker to unrelated later saves. */
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
pending = null
|
||||
restoring = null
|
||||
confirmed = null
|
||||
}
|
||||
|
||||
private fun matches(record: TrailerReturnRecord, current: CurrentUser?): Boolean =
|
||||
current != null && !current.user.isProtected && !current.user.accessToken.isNullOrBlank() &&
|
||||
current.server.id.toString() == record.serverId && current.user.id.toString() == record.userId
|
||||
|
||||
private fun isWithinWindow(record: TrailerReturnRecord): Boolean {
|
||||
if (record.startedElapsed < 0 || record.bootCount < 0) return false
|
||||
val age = clock.readElapsedMillis() - record.startedElapsed
|
||||
return age >= 0 && age < RETURN_WINDOW_MILLIS && clock.readBootCount() == record.bootCount
|
||||
}
|
||||
|
||||
private fun titleDestination(destination: Destination?): String? =
|
||||
destination?.toWolfskinRoute()?.takeIf { it.startsWith("title/") }
|
||||
|
||||
companion object {
|
||||
internal const val SAVED_STATE_KEY = "wolfskin.trailer.return"
|
||||
private const val RETURN_WINDOW_MILLIS = 10L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class TrailerReturnRecord(
|
||||
val serverId: String,
|
||||
val userId: String,
|
||||
val route: String,
|
||||
val startedElapsed: Long,
|
||||
val bootCount: Int,
|
||||
)
|
||||
|
|
@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
val conductarr: ConductarrSession,
|
||||
artwork: WolfskinArtwork,
|
||||
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||
private val trailerReturn: TrailerReturnState,
|
||||
) : ViewModel() {
|
||||
init { artwork.configure(context) }
|
||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||
|
|
@ -107,10 +108,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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue