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:
Damontecres 2026-08-21 15:04:07 -04:00 • committed by GitHub
parent 39523aa6e2
commit 4b927e6b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 736 additions and 34 deletions

View file

@ -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) }
}
}

View file

@ -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)
}
}
}

View file

@ -1,11 +1,15 @@
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 io.mockk.MockKMatcherScope
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.NameGuidPair
import org.jellyfin.sdk.model.api.UserDto
fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() }
@ -75,3 +79,35 @@ fun playlist(
seriesId = null,
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),
)