Fix Next Up channel launch sign in (#1886)
## Description When parsing intents, not every path for authentication was checked, so launching via the "Next Up" OS channel wouldn't check for a profile PIN. This PR fixes that. Additionally, the "Next Up" OS channel launch wasn't checking the correct parameter to ensure the back stack was written properly. ### Related issues Fixes #1883 ### Testing I manually test on the emulator with these test cases: - No profile PIN, cold start - No profile PIN, hot start - Profile PIN, cold start - Profile PIN, hot start - No profile PIN, launch via "Next Up" channel - No profile PIN, launch via intent - Profile PIN, launch via "Next Up" channel - Profile PIN, launch via intent Also added unit tests for intent-based and regular launching which covers those cases and more. ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
39523aa6e2
commit
4b927e6b7b
5 changed files with 736 additions and 34 deletions
|
|
@ -439,9 +439,16 @@ class MainActivityViewModel
|
||||||
val result = intent?.let { intentService.parseIntent(intent) }
|
val result = intent?.let { intentService.parseIntent(intent) }
|
||||||
when (result) {
|
when (result) {
|
||||||
is IntentResult.Error -> {
|
is IntentResult.Error -> {
|
||||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
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)
|
Timber.e("Error parsing intent: %s", result.message)
|
||||||
showToast(context, "Invalid intent: ${result.message}")
|
showToast(context, result.message)
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,7 +512,7 @@ class MainActivityViewModel
|
||||||
prefs.currentUserId?.toUUIDOrNull(),
|
prefs.currentUserId?.toUUIDOrNull(),
|
||||||
)
|
)
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
if (current.user.hasPin || current.user.requireLogin) {
|
if (current.user.isProtected) {
|
||||||
setupNavigationManager.navigateTo(
|
setupNavigationManager.navigateTo(
|
||||||
SetupDestination.UserList(
|
SetupDestination.UserList(
|
||||||
current.server,
|
current.server,
|
||||||
|
|
|
||||||
|
|
@ -29,49 +29,26 @@ class IntentService
|
||||||
Timber.v("Intent data: %s", intent.data)
|
Timber.v("Intent data: %s", intent.data)
|
||||||
val action = intent.action ?: intent.data?.host
|
val action = intent.action ?: intent.data?.host
|
||||||
if (
|
if (
|
||||||
intent.getStringParam("type") == null &&
|
intent.getStringParam(INTENT_ITEM_TYPE) == null &&
|
||||||
(action == Intent.ACTION_MAIN || action.isNullOrBlank())
|
(action == Intent.ACTION_MAIN || action.isNullOrBlank())
|
||||||
) {
|
) {
|
||||||
|
// Normal app launch
|
||||||
return IntentResult.NoOp
|
return IntentResult.NoOp
|
||||||
}
|
}
|
||||||
|
|
||||||
val userId = intent.getStringParam("userId")?.toUUIDOrNull()
|
val result = prepare(intent)
|
||||||
val serverId = intent.getStringParam("serverId")?.toUUIDOrNull()
|
if (result != null) {
|
||||||
if (userId != null && serverId != null) {
|
return result
|
||||||
Timber.v("Intent switches user")
|
|
||||||
val user =
|
|
||||||
serverRepository.serverDao.getUser(serverId, userId)
|
|
||||||
?: return IntentResult.Error("User not found")
|
|
||||||
if (!user.isProtected) {
|
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
} else {
|
|
||||||
return IntentResult.Error("Cannot switch to specified user")
|
|
||||||
}
|
|
||||||
} else if (serverRepository.currentUser == null) {
|
|
||||||
Timber.v("No current user, so restoring last")
|
|
||||||
val appPrefs = userPreferencesService.flow.first().appPreferences
|
|
||||||
if (appPrefs.signInAutomatically) {
|
|
||||||
val userId = appPrefs.currentUserId.toUUIDOrNull()
|
|
||||||
val serverId = appPrefs.currentServerId.toUUIDOrNull()
|
|
||||||
if (userId != null && serverId != null) {
|
|
||||||
serverRepository.restoreSession(serverId, userId)
|
|
||||||
?: return IntentResult.Error("Error restoring user")
|
|
||||||
} else {
|
|
||||||
return IntentResult.Error("Could not auto sign-in, specify a server & user")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return IntentResult.Error("Auto sign-in not enabled, specify a server & user")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent.getStringParam("type") != null) {
|
// Existence of this key implies launching from Play Next channel
|
||||||
|
if (intent.getStringParam(INTENT_ITEM_TYPE) != null) {
|
||||||
return getDestinationFromChannel(intent)?.let {
|
return getDestinationFromChannel(intent)?.let {
|
||||||
IntentResult.Target(
|
IntentResult.Target(
|
||||||
destinations = listOf(it),
|
destinations = listOf(it),
|
||||||
addHomeToBackStack = false,
|
addHomeToBackStack = false,
|
||||||
)
|
)
|
||||||
}
|
} ?: IntentResult.Error("Invalid parameters")
|
||||||
?: IntentResult.Error("Invalid parameters")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action == Intent.ACTION_SEARCH || action == "search") {
|
if (action == Intent.ACTION_SEARCH || action == "search") {
|
||||||
|
|
@ -130,6 +107,49 @@ class IntentService
|
||||||
return IntentResult.Target(destinations)
|
return IntentResult.Target(destinations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal suspend fun prepare(intent: Intent): IntentResult? {
|
||||||
|
val appPrefs = userPreferencesService.flow.first().appPreferences
|
||||||
|
|
||||||
|
val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull()
|
||||||
|
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")
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
IntentResult.Error("Cannot switch to specified 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()
|
||||||
|
|
||||||
|
if (userId != null && serverId != null) {
|
||||||
|
val current =
|
||||||
|
serverRepository.restoreSession(serverId, userId)
|
||||||
|
?: return IntentResult.Error("Error restoring user")
|
||||||
|
if (current.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
|
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
|
||||||
|
|
||||||
private fun Intent.getLongParam(key: String) =
|
private fun Intent.getLongParam(key: String) =
|
||||||
|
|
@ -173,6 +193,8 @@ class IntentService
|
||||||
const val INTENT_EPISODE_NUMBER = "epNum"
|
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
const val INTENT_SERVER_ID = "serverId"
|
||||||
|
const val INTENT_USER_ID = "userId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
|
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.toServerString
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class IntentServiceTest {
|
||||||
|
private val api: ApiClient = mockk()
|
||||||
|
private val serverRepository: ServerRepository = mockk()
|
||||||
|
private val userPreferencesService: UserPreferencesService = mockk()
|
||||||
|
|
||||||
|
lateinit var intentService: IntentService
|
||||||
|
|
||||||
|
private val serverId = UUID.randomUUID()
|
||||||
|
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() {
|
||||||
|
intentService = IntentService(api, serverRepository, userPreferencesService)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
|
||||||
|
every { userPreferencesService.flow } returns
|
||||||
|
flow {
|
||||||
|
emit(
|
||||||
|
UserPreferences(
|
||||||
|
AppPreferences.getDefaultInstance().update(block),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in with unprotected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||||
|
|
||||||
|
val intent = Intent()
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertNull(result)
|
||||||
|
|
||||||
|
coVerify { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign disabled in with unprotected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = false
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||||
|
|
||||||
|
val intent = Intent()
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertTrue(result is IntentResult.Error)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, hot load, with protected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, cold load, with protected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||||
|
|
||||||
|
val intent = Intent()
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertTrue(result is IntentResult.Error)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, no current user`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = ""
|
||||||
|
currentUserId = ""
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test user specified with unprotected profile`() =
|
||||||
|
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
|
||||||
|
|
||||||
|
val intent =
|
||||||
|
Intent().apply {
|
||||||
|
putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString())
|
||||||
|
putExtra(IntentService.INTENT_USER_ID, userId.toServerString())
|
||||||
|
}
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertNull(result)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test user specified with protected profile`() =
|
||||||
|
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
|
||||||
|
|
||||||
|
val intent =
|
||||||
|
Intent().apply {
|
||||||
|
putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString())
|
||||||
|
putExtra(IntentService.INTENT_USER_ID, userId.toServerString())
|
||||||
|
}
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertTrue(result is IntentResult.Error)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test user specified does not exist`() =
|
||||||
|
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
|
||||||
|
|
||||||
|
val intent =
|
||||||
|
Intent().apply {
|
||||||
|
putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString())
|
||||||
|
putExtra(IntentService.INTENT_USER_ID, userId.toServerString())
|
||||||
|
}
|
||||||
|
val result = intentService.prepare(intent)
|
||||||
|
assertTrue(result is IntentResult.Error)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,415 @@
|
||||||
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
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.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
|
||||||
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||||
|
import com.github.damontecres.wholphin.services.IntentResult
|
||||||
|
import com.github.damontecres.wholphin.services.IntentService
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.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
|
||||||
|
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.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.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()
|
||||||
|
private val serverDao: JellyfinServerDao = mockk()
|
||||||
|
|
||||||
|
val viewModel =
|
||||||
|
MainActivityViewModel(
|
||||||
|
context,
|
||||||
|
preferences,
|
||||||
|
serverRepository,
|
||||||
|
setupNavigationManager,
|
||||||
|
navigationManager,
|
||||||
|
deviceProfileService,
|
||||||
|
backdropService,
|
||||||
|
appUpgradeHandler,
|
||||||
|
intentService,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val serverId = UUID.randomUUID()
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||||
|
|
||||||
|
viewModel.appStart(null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign disabled in with current server`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = false
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
|
||||||
|
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
|
||||||
|
|
||||||
|
viewModel.appStart(null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 1) { serverDao.getServer(serverId) }
|
||||||
|
coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
|
||||||
|
val args = mutableListOf<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||||
|
assertEquals(2, 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)
|
||||||
|
destination as SetupDestination.UserList
|
||||||
|
assertEquals(currentUser.server, destination.server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, hot load, with protected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||||
|
|
||||||
|
viewModel.appStart(null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
verify { serverDao.getServer(serverId) }
|
||||||
|
val args = mutableListOf<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||||
|
assertEquals(2, 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)
|
||||||
|
destination as SetupDestination.UserList
|
||||||
|
assertEquals(currentUser.server, destination.server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, cold load, with protected profile`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = serverId.toServerString()
|
||||||
|
currentUserId = userId.toServerString()
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
|
||||||
|
|
||||||
|
viewModel.appStart(null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
|
||||||
|
val slot = slot<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
|
slot.captured.let { destination ->
|
||||||
|
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||||
|
destination as SetupDestination.UserList
|
||||||
|
assertEquals(currentUser.server, destination.server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test auto sign in, no current user`() =
|
||||||
|
runTest {
|
||||||
|
setupPreferences {
|
||||||
|
signInAutomatically = true
|
||||||
|
currentServerId = ""
|
||||||
|
currentUserId = ""
|
||||||
|
}
|
||||||
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
|
coEvery { serverRepository.restoreSession(null, null) } returns null
|
||||||
|
|
||||||
|
viewModel.appStart(null)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
|
||||||
|
val slot = slot<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
|
slot.captured.let { destination ->
|
||||||
|
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test intent result is noop`() =
|
||||||
|
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.NoOp
|
||||||
|
|
||||||
|
viewModel.appStart(Intent())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test intent result is error, has server`() =
|
||||||
|
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 slot = slot<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
|
slot.captured.let { destination ->
|
||||||
|
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
|
||||||
|
destination as SetupDestination.UserList
|
||||||
|
assertEquals(currentUser.server, destination.server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test intent result is error, does not have server`() =
|
||||||
|
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 slot = slot<SetupDestination>()
|
||||||
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
|
slot.captured.let { destination ->
|
||||||
|
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test intent result is target, has server`() =
|
||||||
|
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.Target(
|
||||||
|
destinations = listOf(Destination.Favorites),
|
||||||
|
addHomeToBackStack = true,
|
||||||
|
)
|
||||||
|
val backstack = mutableListOf<Destination>()
|
||||||
|
every { navigationManager.backStack } returns backstack
|
||||||
|
|
||||||
|
viewModel.appStart(Intent())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
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)
|
||||||
|
destination as SetupDestination.AppContent
|
||||||
|
assertEquals(currentUser, destination.current)
|
||||||
|
}
|
||||||
|
// Note: navigation manager internally handles Home destination, so it won't be in this list
|
||||||
|
assertEquals(1, backstack.size)
|
||||||
|
backstack[0].let { destination ->
|
||||||
|
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
|
||||||
|
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.Target(
|
||||||
|
destinations = listOf(Destination.Favorites),
|
||||||
|
addHomeToBackStack = false,
|
||||||
|
)
|
||||||
|
val backstack = mutableListOf<Destination>()
|
||||||
|
every { navigationManager.backStack } returns backstack
|
||||||
|
|
||||||
|
viewModel.appStart(Intent())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
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)
|
||||||
|
destination as SetupDestination.AppContent
|
||||||
|
assertEquals(currentUser, destination.current)
|
||||||
|
}
|
||||||
|
assertEquals(1, backstack.size)
|
||||||
|
backstack[0].let { destination ->
|
||||||
|
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Test intent result is target, has no server`() =
|
||||||
|
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.Target(
|
||||||
|
destinations = listOf(Destination.Favorites),
|
||||||
|
addHomeToBackStack = true,
|
||||||
|
)
|
||||||
|
val backstack = mutableListOf<Destination>()
|
||||||
|
every { navigationManager.backStack } returns backstack
|
||||||
|
|
||||||
|
viewModel.appStart(Intent())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import io.mockk.MockKMatcherScope
|
import io.mockk.MockKMatcherScope
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
|
||||||
fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() }
|
fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() }
|
||||||
|
|
||||||
|
|
@ -75,3 +79,35 @@ fun playlist(
|
||||||
seriesId = null,
|
seriesId = null,
|
||||||
genreItems = genres,
|
genreItems = genres,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11")
|
||||||
|
|
||||||
|
fun user(
|
||||||
|
serverId: UUID,
|
||||||
|
userId: UUID = UUID.randomUUID(),
|
||||||
|
) = JellyfinUser(
|
||||||
|
rowId = 1,
|
||||||
|
id = userId,
|
||||||
|
serverId = serverId,
|
||||||
|
name = "test-user",
|
||||||
|
accessToken = "token",
|
||||||
|
pin = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun userDto(userId: UUID) =
|
||||||
|
UserDto(
|
||||||
|
id = userId,
|
||||||
|
name = "test-user",
|
||||||
|
serverName = "test server",
|
||||||
|
hasPassword = true,
|
||||||
|
hasConfiguredPassword = true,
|
||||||
|
hasConfiguredEasyPassword = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun currentUser(
|
||||||
|
serverId: UUID = UUID.randomUUID(),
|
||||||
|
userId: UUID = UUID.randomUUID(),
|
||||||
|
) = CurrentUser(
|
||||||
|
server(serverId),
|
||||||
|
user(userId),
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue