Updates to session restoration (#1905)

## Description
This PR reworks some of the internal handling of changing users and auto
sign in.

1. Most recently used server/user is migrated from `AppPreferences` to
`MostRecentServerProvider`
2. Checking if a user profile is protected occurs at a lower level in
`ServerRepository` so fewer services need to check for this
3. Now always defaults to the most recent server's user list in cases
where the most recent user can't be switched to such as if the profile
is protected, auto signin is disabled, or and error occurred

The default implementation for `MostRecentServerProvider` uses Android's
`SharedPreferences`. `SharedPreferences` was already was being used to
track the most recent server for crash reporting (the crash reporting
process cannot use the Hilt DI). This PR enhances this and adds a better
API for saving/retrieving.

This also removes the last bit of "user specific" data from
`AppPreferences`.

### Related issues
None specific, but there have been a few related to auto sign-in &
intents that were recently fixed. This PR is a more detailed approach to
those same problems.

### Testing
Manually via the emulator. There are also unit tests for most (all?) of
the different settings and state combinations.

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-11 20:11:09 -04:00 • committed by GitHub
parent a11cd9fb74
commit f01c5d367c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 786 additions and 493 deletions

View file

@ -9,6 +9,7 @@ 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
@ -75,7 +76,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -84,7 +84,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@ -436,120 +435,27 @@ class MainActivityViewModel
viewModelScope.launchDefault {
mutex.withLock {
try {
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@withLock
}
handleAppUpgrade()
} catch (ex: Exception) {
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
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@withLock
}
IntentResult.NoOp,
null,
-> {
// No-op, proceed below
}
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 {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
if (prefs.signInAutomatically && !profileProtected) {
val current =
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
)
if (current != null) {
if (current.user.isProtected) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
current.server,
),
)
} else {
// Restored
setupNavigationManager.navigateTo(
SetupDestination.AppContent(
current,
),
)
}
} else {
// Did not restore
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
setupNavigationManager.navigateTo(
SetupDestination.UserList(
currentServer,
),
)
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
@ -574,6 +480,103 @@ class MainActivityViewModel
}
}
}
/**
* 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.restoreLastSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
}
}
}
private val Destination?.isPlayback: Boolean

View file

@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
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.ScreensaverService
@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@ -75,8 +74,10 @@ class WholphinDreamService :
lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault {
if (serverRepository.current.value == null) {
val prefs = preferencesDataStore.data.first()
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
val result = serverRepository.restoreLastSession()
if (result !is RestoredSession.Success) {
Timber.w("Could not restore user session")
}
}
}
}

View file

@ -0,0 +1,181 @@
package com.github.damontecres.wholphin.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.github.damontecres.wholphin.data.MostRecentServer.None
import com.github.damontecres.wholphin.data.MostRecentServer.Server
import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toServerString
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
/**
* Represents the status of the most recently used server
*
* This can be read/written to shared preferences
*/
sealed interface MostRecentServer {
/**
* There is no recently used server
*/
data object None : MostRecentServer
/**
* The most recently used server without a recent user
*/
data class Server(
val serverId: UUID,
val serverUrl: String,
) : MostRecentServer
/**
* Most recently used server and user
*/
data class ServerAndUser(
val serverId: UUID,
val serverUrl: String,
val userId: UUID,
val accessToken: String,
) : MostRecentServer {
override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)"
}
companion object {
fun from(current: CurrentUser?): MostRecentServer =
if (current != null && current.user.accessToken.isNotNullOrBlank()) {
ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken)
} else if (current != null) {
Server(current.server.id, current.server.url)
} else {
None
}
}
}
interface MostRecentServerProvider {
/**
* Get the [MostRecentServer] from the shared preferences in [context]
*/
fun get(): MostRecentServer
/**
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
*/
suspend fun save(current: CurrentUser)
/**
* Clear the most recent server and user
*/
suspend fun clear()
/**
* CLear the most recent user, leaving the server
*/
suspend fun clearUser()
}
/**
* A [MostRecentServerProvider] that stores in [SharedPreferences]
*/
class MostRecentServerSharedPreferences(
context: Context,
) : MostRecentServerProvider {
private val prefs = getServerSharedPreferences(context)
/**
* Get the [MostRecentServer] from the shared preferences in [context]
*/
override fun get(): MostRecentServer {
val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
val serverUrl = prefs.getString(SERVER_URL_KEY, null)
val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull()
val accessToken = prefs.getString(USER_TOKEN_KEY, null)
return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) {
ServerAndUser(serverId, serverUrl, userId, accessToken)
} else if (serverId != null && serverUrl.isNotNullOrBlank()) {
Server(serverId, serverUrl)
} else {
None
}
}
/**
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
*/
override suspend fun save(current: CurrentUser) {
prefs.edit(true) {
putString(SERVER_ID_KEY, current.server.id.toServerString())
putString(SERVER_URL_KEY, current.server.url)
putString(USER_ID_KEY, current.user.id.toServerString())
putString(USER_TOKEN_KEY, current.user.accessToken)
}
}
override suspend fun clear() {
prefs.edit(true) {
remove(SERVER_ID_KEY)
remove(SERVER_URL_KEY)
remove(USER_ID_KEY)
remove(USER_TOKEN_KEY)
}
}
override suspend fun clearUser() {
prefs.edit(true) {
remove(USER_ID_KEY)
remove(USER_TOKEN_KEY)
}
}
private fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
"${context.packageName}_server",
Context.MODE_PRIVATE,
)
companion object {
private const val SERVER_ID_KEY = "current.server.id"
private const val SERVER_URL_KEY = "current.server.url"
private const val USER_ID_KEY = "current.user.id"
private const val USER_TOKEN_KEY = "current.user.accessToken"
}
}
/**
* The result of restoring a session
*/
sealed interface RestoredSession {
val destination: SetupDestination
/**
* Session could not be restored
*/
data object None : RestoredSession {
override val destination: SetupDestination
get() = SetupDestination.ServerList
}
/**
* Only the server should be restored, so a user still need to be chosen
*/
data class ServerOnly(
val server: JellyfinServer,
) : RestoredSession {
override val destination: SetupDestination
get() = SetupDestination.UserList(server)
}
/**
* Session was restored successfully
*/
data class Success(
val currentUser: CurrentUser,
) : RestoredSession {
override val destination: SetupDestination
get() = SetupDestination.AppContent(currentUser)
}
}

View file

@ -1,15 +1,10 @@
package com.github.damontecres.wholphin.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
@ -49,8 +44,8 @@ class ServerRepository
val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val mostRecentServerProvider: MostRecentServerProvider,
) {
private var _current = MutableStateFlow<CurrentUser?>(null)
val current: StateFlow<CurrentUser?> = _current
@ -88,10 +83,11 @@ class ServerRepository
}
apiClient.update(baseUrl = server.url, accessToken = null)
_current.value = null
mostRecentServerProvider.clearUser()
}
/**
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user
* Saves the server & user to the app database and updates the [ApiClient] to use this server & user
*/
suspend fun changeUser(
server: JellyfinServer,
@ -119,70 +115,101 @@ class ServerRepository
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = updatedServer.id.toServerString()
currentUserId = updatedUser.id.toServerString()
}.build()
}
val currentUser = CurrentUser(updatedServer, updatedUser)
withContext(WholphinDispatchers.Main) {
_current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto)
}
getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
mostRecentServerProvider.save(currentUser)
_current.value = currentUser
_currentUserDto.value = ServerUserConfig(userDto)
return@withContext currentUser
}
/**
* Restores a session for the given server & user such as when the app reopens
* Try to restore the most recent session
*
* If user has a PIN, this returns false
* Does not check if auto sign-in is enabled, but does check if the user profile is protected
*/
suspend fun restoreSession(
suspend fun restoreLastSession(): RestoredSession {
val recentServer = mostRecentServerProvider.get()
Timber.d("restoreLastSession: recentServer=%s", recentServer)
val result =
when (recentServer) {
MostRecentServer.None -> {
RestoredSession.None
}
is MostRecentServer.Server -> {
val server = serverDao.getServer(recentServer.serverId)
if (server != null) {
RestoredSession.ServerOnly(server.server)
} else {
RestoredSession.None
}
}
is MostRecentServer.ServerAndUser -> {
val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
if (currentUser != null) {
if (currentUser.user.isProtected) {
RestoredSession.ServerOnly(currentUser.server)
} else {
RestoredSession.Success(currentUser)
}
} else {
getMostRecentServerInternal(recentServer)
}
}
}
return result
}
/**
* Get the most recently used server, if any
*/
suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
val recentServer = recentServer ?: mostRecentServerProvider.get()
val serverId =
when (recentServer) {
MostRecentServer.None -> null
is MostRecentServer.Server -> recentServer.serverId
is MostRecentServer.ServerAndUser -> recentServer.serverId
}
val server = serverId?.let { serverDao.getServer(serverId)?.server }
return if (server != null) {
RestoredSession.ServerOnly(server)
} else {
RestoredSession.None
}
}
/**
* Try to change to the given server & user. If the user's profile is protected, returns null
*
* @return the server/user or null if the session could not be restored
*/
suspend fun tryChangeUser(
serverId: UUID?,
userId: UUID?,
): CurrentUser? {
if (serverId == null || userId == null) {
_current.value = null
return null
}
val serverAndUsers =
withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
val current = _current.value
if (current != null && current.server.id == serverId && current.user.id == userId) {
Timber.v("Restoring session for current user, so shortcut")
apiClient.update(
baseUrl = current.server.url,
accessToken = current.user.accessToken,
)
return current
): CurrentUser? =
withContext(ioDispatcher) {
return@withContext if (serverId == null || userId == null) {
_current.value = null
null
} else {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null) {
return changeUser(serverAndUsers.server, user)
val serverAndUsers = serverDao.getServer(serverId)
if (serverAndUsers != null) {
val user = serverAndUsers.users.firstOrNull { it.id == userId }
if (user != null && !user.isProtected) {
changeUser(serverAndUsers.server, user)
} else {
null
}
} else {
null
}
}
}
return null
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
fun closeSession() {
_current.value = null
}
/**
* Given a successful [AuthenticationResult], switch to the user that just authenticated
@ -244,13 +271,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentUserId = ""
}.build()
}
mostRecentServerProvider.clearUser()
apiClient.update(accessToken = null)
}
withContext(ioDispatcher) {
@ -263,14 +284,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
mostRecentServerProvider.clear()
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(ioDispatcher) {
@ -279,14 +293,7 @@ class ServerRepository
}
suspend fun switchServerOrUser() {
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
mostRecentServerProvider.clear()
}
suspend fun updateUserAuth(
@ -335,17 +342,6 @@ class ServerRepository
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
}
}
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
"${context.packageName}_server",
Context.MODE_PRIVATE,
)
const val SERVER_URL_KEY = "current.server"
const val ACCESS_TOKEN_KEY = "current.accessToken"
}
}
@Serializable

View file

@ -6,7 +6,9 @@ import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -36,6 +38,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@ -56,6 +59,7 @@ class AppUpgradeHandler
private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
private val mostRecentServerProvider: MostRecentServerProvider,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -442,5 +446,29 @@ class AppUpgradeHandler
}
}
}
if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
try {
val prefs = appPreferences.data.firstOrNull()
val serverId = prefs?.currentServerId?.toUUIDOrNull()
val userId = prefs?.currentUserId?.toUUIDOrNull()
if (serverId != null && userId != null) {
val server = serverDao.getServer(serverId)
val user = server?.users?.firstOrNull { it.id == userId }
if (server != null && user != null) {
Timber.i("Migrating most recent server")
mostRecentServerProvider.save(CurrentUser(server.server, user))
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error during migration for most recent server")
}
appPreferences.updateData {
it.update {
currentServerId = ""
currentUserId = ""
}
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
@ -188,9 +189,8 @@ class HomeSettingsService
/**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
*
* This is public only for testing
*/
@VisibleForTesting
fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
@ -114,6 +116,7 @@ class IntentService
return IntentResult.Target(destinations, true)
}
@VisibleForTesting
internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences
@ -121,39 +124,33 @@ class IntentService
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) {
Timber.v("Intent switches user")
val user = serverRepository.serverDao.getUser(serverId, userId)
if (user != null && !user.isProtected) {
serverRepository.restoreSession(serverId, userId)
?: IntentResult.Error("Error restoring user")
val user = serverRepository.tryChangeUser(serverId, userId)
if (user != null) {
null
} else {
IntentResult.Error("Cannot switch to specified user")
IntentResult.Error("Error restoring user")
}
} else {
val profileProtected =
serverRepository.current.value
?.user
?.isProtected == true
if (appPrefs.signInAutomatically && !profileProtected) {
Timber.v("No current user, so restoring last")
val userId = appPrefs.currentUserId?.toUUIDOrNull()
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
} else if (appPrefs.signInAutomatically) {
Timber.v("No current user, so restoring last")
val result = serverRepository.restoreLastSession()
Timber.v("Restored result=%s", result)
when (result) {
RestoredSession.None,
is RestoredSession.ServerOnly,
-> {
IntentResult.Error("Could not auto sign-in, specify a server & user")
}
if (userId != null && serverId != null) {
val current =
serverRepository.restoreSession(serverId, userId)
?: return IntentResult.Error("Error restoring user")
if (current.user.isProtected) {
is RestoredSession.Success -> {
if (result.currentUser.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected")
} else {
null
}
} else {
IntentResult.Error("Could not auto sign-in, specify a server & user")
}
} else {
IntentResult.Error("Auto sign-in not enabled or user is protected")
}
} else {
IntentResult.Error("Auto sign-in not enabled")
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope
@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -52,12 +54,8 @@ class LatestNextUpWorker
try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
val result = serverRepository.restoreLastSession()
if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}
@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
@VisibleForTesting
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
init {

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -63,12 +64,8 @@ class SuggestionsWorker
}
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
val result = serverRepository.restoreLastSession()
if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}

View file

@ -4,6 +4,8 @@ import android.content.Context
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
@ -188,6 +190,12 @@ object AppModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
@Provides
@Singleton
fun mostRecentServerProvider(
@ApplicationContext context: Context,
): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
}
@Module

View file

@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
@ -70,12 +71,8 @@ class TvProviderWorker
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
val result = serverRepository.restoreLastSession()
if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}

View file

@ -5,6 +5,7 @@ import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.unit.Density
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
@ -198,7 +199,7 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDtoFlow
// Exposed for testing
@VisibleForTesting
val initJob: Job
init {
@ -1110,7 +1111,7 @@ class PlaybackViewModel
}
// Variables for tracking segment state
// Exposed for testing
@VisibleForTesting
internal var segmentJob: Job? = null
private val autoSkippedSegments = mutableSetOf<UUID>()
private val outroShownSegments = mutableSetOf<UUID>()

View file

@ -1,7 +1,7 @@
package com.github.damontecres.wholphin.util
import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService
@ -13,6 +13,7 @@ import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderException
import org.acra.sender.ReportSenderFactory
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@ -39,26 +40,10 @@ class CrashReportSender : ReportSender {
errorContent: CrashReportData,
) {
Timber.v("Attempting to send crash report")
val prefs = ServerRepository.getServerSharedPreferences(context)
val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null)
val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null)
if (serverUrl != null && accessToken != null) {
try {
val okHttpClient =
OkHttpClient
.Builder()
.build()
val okHttpFactory = OkHttpFactory(okHttpClient)
val api =
createJellyfin {
this.context = context
clientInfo = AppModule.clientInfo(context)
deviceInfo = DeviceModule.deviceInfo(context)
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
try {
val recentServer = AppModule.mostRecentServerProvider(context).get()
val api = recentServer.createApi(context)
if (api != null) {
val obj = JSONObject()
for ((key, value) in errorContent.toMap()) {
obj.put(key, value)
@ -75,13 +60,32 @@ class CrashReportSender : ReportSender {
""".trimIndent() + jsonStr,
).content.fileName
Timber.i("Sent report to $serverUrl, filename=$filename")
Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename)
}
} catch (ex: Exception) {
throw ReportSenderException("Exception while sending crash report", ex)
} else {
throw ReportSenderException("Could not find valid server and/or credentials to use")
}
} else {
throw ReportSenderException("Could not find valid server and/or credentials to use")
} catch (ex: Exception) {
throw ReportSenderException("Exception while sending crash report", ex)
}
}
}
private fun MostRecentServer.createApi(context: Context): ApiClient? =
(this as? MostRecentServer.ServerAndUser)?.let {
val okHttpClient =
OkHttpClient
.Builder()
.build()
val okHttpFactory = OkHttpFactory(okHttpClient)
val api =
createJellyfin {
this.context = context
clientInfo = AppModule.clientInfo(context)
deviceInfo = DeviceModule.deviceInfo(context)
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
api
}

View file

@ -233,9 +233,8 @@ message ExperimentalPreferences {
}
message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1;
string current_user_id = 2;
string current_server_id = 1 [deprecated = true];
string current_user_id = 2 [deprecated = true];
PlaybackPreferences playback_preferences = 3;
HomePagePreferences home_page_preferences = 4;

View file

@ -1,14 +1,12 @@
package com.github.damontecres.wholphin.test
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@ -19,8 +17,10 @@ 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.test.currentUser
import com.github.damontecres.wholphin.test.server
import com.github.damontecres.wholphin.test.user
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -58,7 +58,6 @@ class TestMainActivityViewModel {
private val backdropService: BackdropService = mockk(relaxed = true)
private val appUpgradeHandler: AppUpgradeHandler = mockk()
private val intentService: IntentService = mockk()
private val serverDao: JellyfinServerDao = mockk()
val viewModel =
MainActivityViewModel(
@ -74,6 +73,7 @@ class TestMainActivityViewModel {
)
private val serverId = UUID.randomUUID()
private val server = server(serverId)
private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId)
@ -88,13 +88,6 @@ class TestMainActivityViewModel {
WholphinDispatchers.configure(testDispatcher)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
every { serverRepository.serverDao } returns serverDao
coEvery { serverDao.getServer(serverId) } returns
JellyfinServerUsers(
currentUser.server,
// User list is unused
emptyList(),
)
mockkStatic(Toast::class)
val mockToast = mockk<Toast>()
every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast
@ -121,22 +114,26 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.Success(currentUser)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@ -144,27 +141,27 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = false
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.getMostRecentServer() } returns
RestoredSession.ServerOnly(server)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
verify(exactly = 1) { serverDao.getServer(serverId) }
coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
assertEquals(2, args.size)
Assert.assertEquals(1, args.size)
args[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
}
args[1].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -173,27 +170,30 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
every { serverRepository.current } returns
MutableStateFlow<CurrentUser?>(
protectedCurrentUser,
)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getServer(serverId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
assertEquals(2, args.size)
Assert.assertEquals(1, args.size)
args[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
}
args[1].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -202,22 +202,26 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -226,45 +230,49 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(null, null) } returns null
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
@Test
fun `Test intent result is noop`() =
fun `Test intent result is noop, hot reload`() =
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertFalse(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
}
// val slot = slot<SetupDestination>()
// verify { setupNavigationManager.navigateTo(capture(slot)) }
// slot.captured.let { destination ->
// Assert.assertTrue(
// "destination is ${destination::class}",
// destination is SetupDestination.AppContent,
// )
// destination as SetupDestination.AppContent
// Assert.assertEquals(currentUser, destination.current)
// }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@ -272,22 +280,22 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -296,20 +304,20 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
@ -318,11 +326,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -331,21 +337,27 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
// Note: navigation manager internally handles Home destination, so it won't be in this list
assertEquals(1, backstack.size)
Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
Assert.assertTrue(
"destination is ${destination::class}",
destination is Destination.Favorites,
)
}
}
@ -354,11 +366,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -367,20 +377,26 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
assertEquals(1, backstack.size)
Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
Assert.assertTrue(
"destination is ${destination::class}",
destination is Destination.Favorites,
)
}
}
@ -389,11 +405,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -402,14 +416,17 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
}

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
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.preferences.UserPreferences
@ -20,7 +20,6 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
@ -42,7 +41,6 @@ import org.robolectric.annotation.Config
class IntentServiceTest {
private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk()
private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
@ -64,7 +62,6 @@ class IntentServiceTest {
intentService = IntentService(api, serverRepository, userPreferencesService)
every { api.userLibraryApi } returns userLibraryApi
coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId))
every { serverRepository.serverDao } returns serverDao
}
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
@ -82,11 +79,11 @@ class IntentServiceTest {
private fun setupAutoSignInUnprotected() {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
}
@Test
@ -98,7 +95,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
coVerify { serverRepository.restoreSession(serverId, userId) }
coVerify { serverRepository.restoreLastSession() }
}
@Test
@ -106,17 +103,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = false
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -124,17 +121,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -142,17 +139,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
}
@Test
@ -160,17 +157,16 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -178,12 +174,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
val intent =
Intent().apply {
@ -193,7 +188,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -201,12 +196,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent =
Intent().apply {
@ -216,7 +210,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -224,12 +218,9 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null
coEvery { serverRepository.restoreSession(serverId, userId) } returns null
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
val intent =
Intent().apply {
@ -239,7 +230,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -296,12 +287,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@ -316,9 +306,8 @@ class IntentServiceTest {
assertEquals(0, destinations.size)
assertTrue(result.addHomeToBackStack)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getUser(newServerId, newUserId) }
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
}
@ -333,12 +322,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@ -358,9 +346,8 @@ class IntentServiceTest {
assertEquals(itemId, first.itemId)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getUser(newServerId, newUserId) }
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify { userLibraryApi.getItem(itemId) }
}

View file

@ -1,11 +1,13 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerProvider
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.JellyfinServerUsers
@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.test.nonBlankString
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -29,7 +33,6 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@ -48,6 +51,8 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.After
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -80,14 +85,13 @@ class ServerRepositoryTest {
private val mockUserApi = mockk<UserApi>()
private val mockSystemApi = mockk<SystemApi>()
private val mockSharedPreferences = mockk<SharedPreferences>(relaxed = true)
private val mockMostRecentServerProvider = mockk<MostRecentServerProvider>()
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
WholphinDispatchers.configure(testDispatcher)
every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences
every { mockApiClient.userApi } returns mockUserApi
every { mockApiClient.systemApi } returns mockSystemApi
coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto)
@ -102,9 +106,15 @@ class ServerRepositoryTest {
emptyMap(),
)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
JellyfinServerUsers(server, listOf(user))
coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs
every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1")
every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device")
every { mockApiClient.update(any(), any(), any(), any()) } just Runs
coEvery { mockMostRecentServerProvider.save(any()) } just Runs
coEvery { mockMostRecentServerProvider.clear() } just Runs
coEvery { mockMostRecentServerProvider.clearUser() } just Runs
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -119,8 +129,8 @@ class ServerRepositoryTest {
mockJellyfin,
mockJellyfinServerDao,
mockApiClient,
dataStore,
testDispatcher,
mockMostRecentServerProvider,
)
private val serverId = UUID.randomUUID()
@ -194,24 +204,24 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) }
}
@Test
fun `Test restoreUser via changeUser`() =
runTest {
val user = user.copy(pin = null)
every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs
every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
Assert.assertNull(serverRepository.currentUser)
serverRepository.restoreSession(server.id, user.id)
val result = serverRepository.tryChangeUser(server.id, user.id)
assertTrue(result != null)
assertEquals(serverId, result?.server?.id)
assertEquals(user.id, result?.user?.id)
verify(exactly = 1) {
mockApiClient.update(
@ -226,34 +236,6 @@ class ServerRepositoryTest {
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
}
@Test
fun `Test restoreUser via shortcut`() =
runTest {
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
setUpCurrentUser(serverRepository)
serverRepository.restoreSession(server.id, user.id)
verify(exactly = 1) {
mockApiClient.update(
baseUrl = server.url,
accessToken = user.accessToken,
clientInfo = any(),
deviceInfo = any(),
)
}
verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) }
verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) }
Assert.assertEquals(server, serverRepository.currentServer)
Assert.assertEquals(user, serverRepository.currentUser)
// TODO fix this, need to check userDto too
// Assert.assertEquals(userDto, serverRepository.currentUserDto)
}
@Test
fun `Test remove user`() =
runTest {
@ -308,6 +290,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) }
coVerify { mockMostRecentServerProvider.clearUser() }
}
@Test
@ -351,6 +334,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) }
coVerify { mockMostRecentServerProvider.clear() }
}
@Test
@ -387,11 +371,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@ -430,11 +410,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@ -484,4 +460,99 @@ class ServerRepositoryTest {
}
}
}
@Test
fun `Test restoreLastSession, success`() =
runTest {
val user = user.copy(pin = null)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
JellyfinServerUsers(server, listOf(user))
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.from(CurrentUser(server, user))
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.Success)
val current = (result as RestoredSession.Success).currentUser
assertEquals(server, current.server)
assertEquals(user, current.user)
verify(exactly = 1) {
mockApiClient.update(
baseUrl = server.url,
accessToken = user.accessToken,
clientInfo = any(),
deviceInfo = any(),
)
}
coVerify { mockMostRecentServerProvider.save(any()) }
}
@Test
fun `Test restoreLastSession, user protected`() =
runTest {
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.ServerAndUser(serverId, server.url, userId, "token")
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.ServerOnly)
val current = (result as RestoredSession.ServerOnly)
assertEquals(server, current.server)
}
@Test
fun `Test restoreLastSession, server only`() =
runTest {
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.Server(server.id, server.url)
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.ServerOnly)
val current = (result as RestoredSession.ServerOnly)
assertEquals(server, current.server)
}
@Test
fun `Test restoreLastSession, nothing to restore`() =
runTest {
every { mockMostRecentServerProvider.get() } returns MostRecentServer.None
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.None)
}
}
class TestMostRecentServerProvider : MostRecentServerProvider {
var mostRecentServer: MostRecentServer = MostRecentServer.None
override fun get(): MostRecentServer = mostRecentServer
override suspend fun save(current: CurrentUser) {
mostRecentServer =
if (current.user.accessToken.isNotNullOrBlank()) {
MostRecentServer.ServerAndUser(
current.server.id,
current.server.url,
current.user.id,
current.user.accessToken,
)
} else {
MostRecentServer.Server(
current.server.id,
current.server.url,
)
}
}
override suspend fun clear() {
mostRecentServer = MostRecentServer.None
}
override suspend fun clearUser() {
when (val s = mostRecentServer) {
MostRecentServer.None -> Unit
is MostRecentServer.Server -> Unit
is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl)
}
}
}

View file

@ -5,8 +5,10 @@ import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
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.preferences.AppPreferences
import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import io.mockk.coEvery
import io.mockk.coVerify
@ -113,16 +115,16 @@ class SuggestionsWorkerTest {
val mockUser = mockk<CurrentUser>()
var restored = false
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
coEvery { mockServerRepository.restoreLastSession() } coAnswers {
restored = true
mockUser
RestoredSession.Success(currentUser())
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
val result = createWorker().doWork()
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
coVerify { mockServerRepository.restoreLastSession() }
assertEquals(ListenableWorker.Result.success(), result)
}

View file

@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.TestMostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
@ -201,6 +203,10 @@ object TestModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
@Provides
@Singleton
fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider()
}
@Module