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:
parent
a11cd9fb74
commit
f01c5d367c
19 changed files with 786 additions and 493 deletions
|
|
@ -0,0 +1,432 @@
|
|||
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.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.preferences.update
|
||||
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.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.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.configure
|
||||
import com.github.damontecres.wholphin.util.reset
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.junit.After
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TestMainActivityViewModel {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val context: Context = mockk()
|
||||
private val preferences: DataStore<AppPreferences> = mockk()
|
||||
private val serverRepository: ServerRepository = mockk()
|
||||
private val setupNavigationManager: SetupNavigationManager = mockk(relaxed = true)
|
||||
private val navigationManager: NavigationManager = mockk(relaxed = true)
|
||||
private val deviceProfileService: DeviceProfileService = mockk()
|
||||
private val backdropService: BackdropService = mockk(relaxed = true)
|
||||
private val appUpgradeHandler: AppUpgradeHandler = mockk()
|
||||
private val intentService: IntentService = mockk()
|
||||
|
||||
val viewModel =
|
||||
MainActivityViewModel(
|
||||
context,
|
||||
preferences,
|
||||
serverRepository,
|
||||
setupNavigationManager,
|
||||
navigationManager,
|
||||
deviceProfileService,
|
||||
backdropService,
|
||||
appUpgradeHandler,
|
||||
intentService,
|
||||
)
|
||||
|
||||
private val serverId = UUID.randomUUID()
|
||||
private val server = server(serverId)
|
||||
private val userId = UUID.randomUUID()
|
||||
private val currentUser = currentUser(serverId, userId)
|
||||
|
||||
private val protectedCurrentUser =
|
||||
CurrentUser(
|
||||
server(serverId),
|
||||
user(serverId, userId).copy(pin = "1234"),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
WholphinDispatchers.configure(testDispatcher)
|
||||
every { appUpgradeHandler.needUpgrade() } returns false
|
||||
every { appUpgradeHandler.copySubfont(any()) } returns Unit
|
||||
mockkStatic(Toast::class)
|
||||
val mockToast = mockk<Toast>()
|
||||
every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast
|
||||
every { mockToast.show() } returns Unit
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
WholphinDispatchers.reset()
|
||||
unmockkStatic(Toast::class)
|
||||
}
|
||||
|
||||
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
|
||||
every { preferences.data } returns
|
||||
flow {
|
||||
emit(
|
||||
AppPreferences.getDefaultInstance().update(block),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in with unprotected profile`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.restoreLastSession() } returns
|
||||
// RestoredSession.Success(currentUser)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
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
|
||||
fun `Test auto sign disabled in with current server`() =
|
||||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = false
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { serverRepository.getMostRecentServer() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
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)) }
|
||||
Assert.assertEquals(1, args.size)
|
||||
args[0].let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in, hot load, with protected profile`() =
|
||||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns
|
||||
MutableStateFlow<CurrentUser?>(
|
||||
protectedCurrentUser,
|
||||
)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
|
||||
val args = mutableListOf<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||
Assert.assertEquals(1, args.size)
|
||||
args[0].let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in, cold load, with protected profile`() =
|
||||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in, no current user`() =
|
||||
runTest {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is noop, hot reload`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertFalse(result)
|
||||
|
||||
// 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
|
||||
fun `Test intent result is error, has server`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
|
||||
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.UserList,
|
||||
)
|
||||
destination as SetupDestination.UserList
|
||||
Assert.assertEquals(currentUser.server, destination.server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is error, does not have server`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(null)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
|
||||
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is target, has server`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
addHomeToBackStack = true,
|
||||
)
|
||||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
val result = viewModel.handleIntent(Intent())
|
||||
assertTrue(result)
|
||||
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
verify { navigationManager.reloadHome() }
|
||||
slot.captured.let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.AppContent,
|
||||
)
|
||||
destination as SetupDestination.AppContent
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
// Note: navigation manager internally handles Home destination, so it won't be in this list
|
||||
Assert.assertEquals(1, backstack.size)
|
||||
backstack[0].let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is Destination.Favorites,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is target, has server, no home backstack`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
addHomeToBackStack = false,
|
||||
)
|
||||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
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 ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.AppContent,
|
||||
)
|
||||
destination as SetupDestination.AppContent
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
Assert.assertEquals(1, backstack.size)
|
||||
backstack[0].let { destination ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is Destination.Favorites,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test intent result is target, has no server`() =
|
||||
runTest(testDispatcher) {
|
||||
setupPreferences {
|
||||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(null)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
coEvery { intentService.parseIntent(any()) } returns
|
||||
IntentResult.Target(
|
||||
destinations = listOf(Destination.Favorites),
|
||||
addHomeToBackStack = true,
|
||||
)
|
||||
val backstack = mutableListOf<Destination>()
|
||||
every { navigationManager.backStack } returns backstack
|
||||
|
||||
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 ->
|
||||
Assert.assertTrue(
|
||||
"destination is ${destination::class}",
|
||||
destination is SetupDestination.ServerList,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue