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`
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
@ -135,12 +135,20 @@ adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee
Full format
```bash
# Switch user, go to item
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--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

View file

@ -55,11 +55,10 @@ class IntentService
val query = intent.getStringParam(SearchManager.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 =
itemId?.let {
try {
api.userLibraryApi
.getItem(itemId)
@ -69,16 +68,24 @@ class IntentService
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 =
when (action) {
Intent.ACTION_VIEW, "view" -> {
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 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? {
@ -195,6 +202,8 @@ class IntentService
const val INTENT_SEASON_ID = "seaId"
const val INTENT_SERVER_ID = "serverId"
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
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.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.assertIs
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.user
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
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.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@ -32,13 +42,16 @@ import org.robolectric.annotation.Config
class IntentServiceTest {
private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk()
private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
lateinit var intentService: IntentService
private val serverId = UUID.randomUUID()
private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId)
private val itemId = UUID.randomUUID()
private val protectedCurrentUser =
CurrentUser(
@ -49,6 +62,9 @@ class IntentServiceTest {
@Before
fun setup() {
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) {
@ -63,9 +79,7 @@ class IntentServiceTest {
}
}
@Test
fun `Test auto sign in with unprotected profile`() =
runTest {
private fun setupAutoSignInUnprotected() {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
@ -73,6 +87,12 @@ class IntentServiceTest {
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
}
@Test
fun `Test auto sign in with unprotected profile`() =
runTest {
setupAutoSignInUnprotected()
val intent = Intent()
val result = intentService.prepare(intent)
@ -221,4 +241,243 @@ class IntentServiceTest {
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.JellyfinUser
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.ibm.icu.impl.Assert
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
import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
fun MockKMatcherScope.nonBlankString() = match<String> { it.isNotNullOrBlank() }
@ -128,3 +132,20 @@ fun currentUser(
server(serverId),
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} ")
}
}