From ffdebd43c3b5d87721062b1db4e31c967e858332 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 15 Sep 2026 13:07:08 -0400 Subject: [PATCH] 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 --- Intents.md | 10 +- .../wholphin/services/IntentService.kt | 41 ++- .../wholphin/services/IntentServiceTest.kt | 273 +++++++++++++++++- .../damontecres/wholphin/test/TestUtils.kt | 21 ++ 4 files changed, 321 insertions(+), 24 deletions(-) diff --git a/Intents.md b/Intents.md index 02511787..7f01a501 100644 --- a/Intents.md +++ b/Intents.md @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index a37a0ed2..2dd0cde5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -55,30 +55,37 @@ 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 = - try { - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it) } - } catch (ex: Exception) { - Timber.w(ex, "Error fetching item %s", itemId) - return IntentResult.Error("Could not fetch item $itemId") + itemId?.let { + try { + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + } catch (ex: Exception) { + 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" -> { - 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 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" } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt index bb4904aa..ee32c7dd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -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,16 +79,20 @@ class IntentServiceTest { } } + private fun setupAutoSignInUnprotected() { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + } + @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) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + 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(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs(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(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) + 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(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) + 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(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs(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(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs(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(result) + + val destinations = result.destinations + assertEquals(1, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + assertIs(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(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(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs(first) + assertIs(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(result) + + val destinations = result.destinations + assertEquals(2, destinations.size) + assertTrue(result.addHomeToBackStack) + val first = destinations.first() + val second = destinations[1] + assertIs(first) + assertIs(second) + + assertEquals(itemId, first.itemId) + assertEquals(itemId, second.itemId) + assertEquals(shuffle, second.shuffle) + assertEquals(position, second.positionMs) + + coVerify { userLibraryApi.getItem(itemId) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 7f51e994..34d962da 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -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 { 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 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} ") + } +}