Update intent parsing to allow for user switching to home page (#1962)

## Description
Makes the `itemId` parameter optional allowing just passing
`serverId`/`userId` to switch the user and load the app at the home page
for that user.

Dev note: I added a new assert function `assertIs` which asserts that an
object `is` of a type. It also has a contract so the compiler will
smart-cast usages after the `assertIs`. Checking a result type is a very
common pattern in the unit tests, so this assert will simplify a lot of
current & future tests.

### Related issues
Closes https://github.com/damontecres/Wholphin/discussions/1824

### Testing
Emulator and added unit tests for `parseIntent`

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-15 13:07:08 -04:00 • committed by GitHub
parent 8effd2b1e7
commit ffdebd43c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 321 additions and 24 deletions

View file

@ -63,7 +63,7 @@ Action: `android.intent.action.VIEW`
Shorthand action: `view` Shorthand action: `view`
Parameters: Parameters:
- `itemId` - Required, the UUID of the media item - `itemId` - Optional, the UUID of the media item, if not set, goes to home screen
### Examples ### Examples
@ -135,12 +135,20 @@ adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee
Full format Full format
```bash ```bash
# Switch user, go to item
adb shell am start \ adb shell am start \
-a android.intent.action.VIEW \ -a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \ -n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587" --es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
# Switch user, go to home screen
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
``` ```
URI based URI based

View file

@ -55,30 +55,37 @@ class IntentService
val query = intent.getStringParam(SearchManager.QUERY) val query = intent.getStringParam(SearchManager.QUERY)
return IntentResult.Target(listOf(Destination.Search(query ?: ""))) return IntentResult.Target(listOf(Destination.Search(query ?: "")))
} }
val itemId =
intent.getStringParam("itemId")?.toUUIDOrNull()
?: return IntentResult.Error("No item id provided")
val itemId = intent.getStringParam("itemId")?.toUUIDOrNull()
val item = val item =
try { itemId?.let {
api.userLibraryApi try {
.getItem(itemId) api.userLibraryApi
.content .getItem(itemId)
.let { BaseItem(it) } .content
} catch (ex: Exception) { .let { BaseItem(it) }
Timber.w(ex, "Error fetching item %s", itemId) } catch (ex: Exception) {
return IntentResult.Error("Could not fetch item $itemId") Timber.w(ex, "Error fetching item %s", itemId)
return IntentResult.Error("Could not fetch item $itemId")
}
} }
val itemDestination = item?.destination()
val itemDestination = item.destination()
val destinations = val destinations =
when (action) { when (action) {
Intent.ACTION_VIEW, "view" -> { Intent.ACTION_VIEW, "view" -> {
listOf(itemDestination) if (itemDestination != null) {
listOf(itemDestination)
} else {
// Will go to the home page
emptyList()
}
} }
"com.github.damontecres.wholphin.PLAYBACK", "play" -> { ACTION_PLAYBACK, "play" -> {
if (itemId == null || itemDestination == null) {
return IntentResult.Error("Cannot start playback with no itemId")
}
val position = intent.getLongParam("position")?.coerceAtLeast(0) val position = intent.getLongParam("position")?.coerceAtLeast(0)
val shuffle = intent.getBooleanExtra("shuffle", false) val shuffle = intent.getBooleanExtra("shuffle", false)
@ -104,7 +111,7 @@ class IntentService
} }
} }
return IntentResult.Target(destinations) return IntentResult.Target(destinations, true)
} }
internal suspend fun prepare(intent: Intent): IntentResult? { internal suspend fun prepare(intent: Intent): IntentResult? {
@ -195,6 +202,8 @@ class IntentService
const val INTENT_SEASON_ID = "seaId" const val INTENT_SEASON_ID = "seaId"
const val INTENT_SERVER_ID = "serverId" const val INTENT_SERVER_ID = "serverId"
const val INTENT_USER_ID = "userId" const val INTENT_USER_ID = "userId"
const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK"
} }
} }

View file

@ -1,24 +1,34 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser 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.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.test.assertIs
import com.github.damontecres.wholphin.test.currentUser import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.test.movie
import com.github.damontecres.wholphin.test.server import com.github.damontecres.wholphin.test.server
import com.github.damontecres.wholphin.test.user import com.github.damontecres.wholphin.test.user
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -32,13 +42,16 @@ import org.robolectric.annotation.Config
class IntentServiceTest { class IntentServiceTest {
private val api: ApiClient = mockk() private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk() private val serverRepository: ServerRepository = mockk()
private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk() private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
lateinit var intentService: IntentService lateinit var intentService: IntentService
private val serverId = UUID.randomUUID() private val serverId = UUID.randomUUID()
private val userId = UUID.randomUUID() private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId) private val currentUser = currentUser(serverId, userId)
private val itemId = UUID.randomUUID()
private val protectedCurrentUser = private val protectedCurrentUser =
CurrentUser( CurrentUser(
@ -49,6 +62,9 @@ class IntentServiceTest {
@Before @Before
fun setup() { fun setup() {
intentService = IntentService(api, serverRepository, userPreferencesService) 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) { private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
@ -63,16 +79,20 @@ 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
}
@Test @Test
fun `Test auto sign in with unprotected profile`() = fun `Test auto sign in with unprotected profile`() =
runTest { runTest {
setupPreferences { setupAutoSignInUnprotected()
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 intent = Intent()
val result = intentService.prepare(intent) val result = intentService.prepare(intent)
@ -221,4 +241,243 @@ class IntentServiceTest {
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
} }
@Test
fun `Test parseIntent with view item`() =
runTest {
setupAutoSignInUnprotected()
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
putExtra("itemId", itemId.toString())
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(1, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
assertIs<Destination.MediaItem>(first)
assertEquals(itemId, first.itemId)
coVerify { userLibraryApi.getItem(itemId) }
}
@Test
fun `Test parseIntent with view and no itemId`() =
runTest {
setupAutoSignInUnprotected()
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(0, destinations.size)
assertTrue(result.addHomeToBackStack)
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
}
@Test
fun `Test parseIntent with view and switch user`() =
runTest {
setupAutoSignInUnprotected()
val newUserId = UUID.randomUUID()
val newServerId = UUID.randomUUID()
val newCurrentUser = currentUser(newServerId, newUserId)
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
putExtra("userId", newUserId.toString())
putExtra("serverId", newServerId.toString())
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
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) { userLibraryApi.getItem(itemId) }
}
@Test
fun `Test parseIntent with view, itemId, and switch user`() =
runTest {
setupAutoSignInUnprotected()
val newUserId = UUID.randomUUID()
val newServerId = UUID.randomUUID()
val newCurrentUser = currentUser(newServerId, newUserId)
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
action = Intent.ACTION_VIEW
putExtra("userId", newUserId.toString())
putExtra("serverId", newServerId.toString())
putExtra("itemId", itemId.toString())
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(1, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
assertIs<Destination.MediaItem>(first)
assertEquals(itemId, first.itemId)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getUser(newServerId, newUserId) }
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify { userLibraryApi.getItem(itemId) }
}
@Test
fun `Test parseIntent with search`() =
runTest {
setupAutoSignInUnprotected()
val intent =
Intent().apply {
action = Intent.ACTION_SEARCH
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(1, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
assertIs<Destination.Search>(first)
assertEquals("", first.query)
}
@Test
fun `Test parseIntent with search query`() =
runTest {
setupAutoSignInUnprotected()
val query = "query123"
val intent =
Intent().apply {
action = Intent.ACTION_SEARCH
putExtra(SearchManager.QUERY, query)
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(1, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
assertIs<Destination.Search>(first)
assertEquals(query, first.query)
}
@Test
fun `Test parseIntent with play but no itemId`() =
runTest {
setupAutoSignInUnprotected()
val intent =
Intent().apply {
action = IntentService.ACTION_PLAYBACK
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Error>(result)
}
@Test
fun `Test parseIntent with play itemId`() =
runTest {
setupAutoSignInUnprotected()
val intent =
Intent().apply {
action = IntentService.ACTION_PLAYBACK
putExtra("itemId", itemId.toString())
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(2, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
val second = destinations[1]
assertIs<Destination.MediaItem>(first)
assertIs<Destination.Playback>(second)
assertEquals(itemId, first.itemId)
assertEquals(itemId, second.itemId)
coVerify { userLibraryApi.getItem(itemId) }
}
@Test
fun `Test parseIntent with play itemId and params`() =
runTest {
setupAutoSignInUnprotected()
val position = 4_000L
val shuffle = true
val intent =
Intent().apply {
action = IntentService.ACTION_PLAYBACK
putExtra("itemId", itemId.toString())
putExtra("position", position)
putExtra("shuffle", shuffle)
}
val result = intentService.parseIntent(intent)
assertIs<IntentResult.Target>(result)
val destinations = result.destinations
assertEquals(2, destinations.size)
assertTrue(result.addHomeToBackStack)
val first = destinations.first()
val second = destinations[1]
assertIs<Destination.MediaItem>(first)
assertIs<Destination.Playback>(second)
assertEquals(itemId, first.itemId)
assertEquals(itemId, second.itemId)
assertEquals(shuffle, second.shuffle)
assertEquals(position, second.positionMs)
coVerify { userLibraryApi.getItem(itemId) }
}
} }

View file

@ -4,12 +4,16 @@ import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.ibm.icu.impl.Assert
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 import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() } fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() }
@ -128,3 +132,20 @@ fun currentUser(
server(serverId), server(serverId),
user(userId), user(userId),
) )
/**
* Asserts that the object is not null and is type `T`
*
* Includes a [contract] to smart-cast the object to T
*/
@OptIn(ExperimentalContracts::class)
inline fun <reified T : Any> assertIs(obj: Any?) {
contract {
returns() implies (obj is T)
}
assertNotNull(obj)
val result = T::class.isInstance(obj)
if (!result) {
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
}
}