mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
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:
parent
8effd2b1e7
commit
ffdebd43c3
4 changed files with 321 additions and 24 deletions
10
Intents.md
10
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} ")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue