From b1f9c32a195629a2a864a830e25e5e4822d70e9c Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 20 Jul 2026 10:04:34 -0400 Subject: [PATCH] Parse intents to search, view, or play media (#1688) ## Description Adds parsing for intents to search, view, or play media based on the action and parameters. This allows for remotely opening Wholphin and starting playback. This is considered experimental, so the syntax or behaviors may change! See the `Intents.md` file in this PR for syntax and examples. ### Related issues Replaces https://github.com/damontecres/Wholphin/pull/738 Closes https://github.com/damontecres/Wholphin/discussions/1095 ### Testing Emulator with the test commands in the docs ## Screenshots N/A ## AI or LLM usage None --- Intents.md | 151 ++++++++++++++++ app/src/main/AndroidManifest.xml | 7 + .../damontecres/wholphin/MainActivity.kt | 129 +++++++------- .../wholphin/data/model/JellyfinServer.kt | 2 + .../wholphin/services/IntentService.kt | 164 ++++++++++++++++++ .../services/tvprovider/TvProviderWorker.kt | 25 +-- .../wholphin/ui/main/SearchPage.kt | 3 +- .../wholphin/ui/nav/Destination.kt | 7 +- .../wholphin/ui/nav/DestinationContent.kt | 3 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 2 +- 10 files changed, 415 insertions(+), 78 deletions(-) create mode 100644 Intents.md create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt diff --git a/Intents.md b/Intents.md new file mode 100644 index 00000000..a083ae7a --- /dev/null +++ b/Intents.md @@ -0,0 +1,151 @@ +# Intents + +> [!WARNING] +> This feature is only available in the develop builds until the next release! + +> [!IMPORTANT] +> This feature is experimental and the exact behaviors and/or parameters may change at any time + +Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media. + +Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`). + +UUID parameters can be specified with or without hyphens. + +If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...` + +## Server/user switching + +You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it. + +If not specified, the app will use the current user if auto sign in is enabled. + +## Search + +Action: `android.intent.action.SEARCH` + +Shorthand action: `search` + +Parameters: +- `query` - Optional, prepopulate the search field + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query "Avengers" +``` + +URI based +```bash +adb shell am start -d 'wholphin://search?query=Avengers' +``` + +Multi-word query: +```bash +# Whole shell command is wrapped in double quotes +adb shell "am start \ + -a android.intent.action.SEARCH \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es query 'Avengers Endgame'" + +adb shell am start -d 'wholphin://search?query=Avengers+Endgame' +adb shell am start -d 'wholphin://search?query=Avengers%20Endgame' +``` + +## View + +This opens the app to the specified item such as an episode or movie + +Action: `android.intent.action.VIEW` + +Shorthand action: `view` + +Parameters: +- `itemId` - Required, the UUID of the media item + + +### Examples + +Full format +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +## Playback + +Action: `com.github.damontecres.wholphin.PLAYBACK` + +Shorthand action: `play` + +Parameters: +- `itemId` - Required, the UUID of the media item +- `position` - Optional, the start position for playback in milliseconds + +### Examples + +Full format +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` + +URI based +```bash +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d' +``` + +Start at 4m30s: +```bash +adb shell am start \ + -a com.github.damontecres.wholphin.PLAYBACK \ + -n 'com.github.damontecres.wholphin/.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ + --el position 270000 + +adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000' +``` + +## More examples + +### Switch user + +Full format +```bash +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" +``` + +URI based +```bash +adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587' +``` + +### Debug build + +Debug builds have a different package, so you must specify the component by its full name: + +```bash +adb shell am start \ + -a android.intent.action.VIEW \ + -n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \ + --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" +``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64e7493c..42404590 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,13 @@ + + + + + + + = 0 && seasonNumber >= 0) { - Destination.SeriesOverview( - itemId = seriesId, - type = BaseItemKind.SERIES, - seasonEpisode = - SeasonEpisodeIds( - seasonId = seasonId, - seasonNumber = seasonNumber, - episodeId = itemId, - episodeNumber = episodeNumber, - ), - ) - } else { - Destination.MediaItem(itemId, type) - } - } else { - null - } - } - fun changeDisplayMode(modeId: Int) { lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes @@ -431,13 +400,6 @@ class MainActivity : AppCompatActivity() { } companion object { - const val INTENT_ITEM_ID = "itemId" - const val INTENT_ITEM_TYPE = "itemType" - const val INTENT_SERIES_ID = "seriesId" - const val INTENT_EPISODE_NUMBER = "epNum" - const val INTENT_SEASON_NUMBER = "seaNum" - const val INTENT_SEASON_ID = "seaId" - private const val KEY_BACK_STACK = "backStack" private const val KEY_EXTERNAL_PLAYER = "extPlayer" @@ -453,16 +415,55 @@ class MainActivityViewModel @param:ApplicationContext private val context: Context, private val preferences: DataStore, val serverRepository: ServerRepository, - private val navigationManager: SetupNavigationManager, + private val setupNavigationManager: SetupNavigationManager, + private val navigationManager: NavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, + private val intentService: IntentService, ) : ViewModel() { private val mutex = Mutex() - fun appStart() { + fun appStart(intent: Intent?) { viewModelScope.launchDefault { mutex.withLock { + try { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, "Invalid intent: ${result.message}") + return@withLock + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + navigationManager.replace(result.destination) + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return@withLock + } + + IntentResult.NoOp, + null, + -> { + // No-op, proceed below + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error parsing intent") + } + try { val needUpgrade = appUpgradeHandler.needUpgrade() if (needUpgrade) { @@ -479,9 +480,9 @@ class MainActivityViewModel val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val profileProtected = - serverRepository.current.value?.user?.let { - it.hasPin || it.requireLogin - } == true + serverRepository.current.value + ?.user + ?.isProtected == true if (prefs.signInAutomatically && !profileProtected) { val current = serverRepository.restoreSession( @@ -490,38 +491,46 @@ class MainActivityViewModel ) if (current != null) { if (current.user.hasPin || current.user.requireLogin) { - navigationManager.navigateTo(SetupDestination.UserList(current.server)) + setupNavigationManager.navigateTo( + SetupDestination.UserList( + current.server, + ), + ) } else { // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + setupNavigationManager.navigateTo( + SetupDestination.AppContent( + current, + ), + ) } } else { // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.Loading) + setupNavigationManager.navigateTo(SetupDestination.Loading) backdropService.clearBackdrop() val currentServerId = prefs.currentServerId?.toUUIDOrNull() if (currentServerId != null) { val currentServer = serverRepository.serverDao.getServer(currentServerId)?.server if (currentServer != null) { - navigationManager.navigateTo( + setupNavigationManager.navigateTo( SetupDestination.UserList( currentServer, ), ) } else { - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } } catch (ex: Exception) { Timber.e(ex, "Error during appStart") - navigationManager.navigateTo(SetupDestination.ServerList) + setupNavigationManager.navigateTo(SetupDestination.ServerList) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 44c19b4a..4c54311c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -71,6 +71,8 @@ data class JellyfinUser( ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() + val isProtected: Boolean get() = hasPin || requireLogin + override fun toString(): String = "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + 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 new file mode 100644 index 00000000..0a043506 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -0,0 +1,164 @@ +package com.github.damontecres.wholphin.services + +import android.app.SearchManager +import android.content.Intent +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.nav.Destination +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class IntentService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + suspend fun parseIntent(intent: Intent): IntentResult { + Timber.v("Parsing intent %s", intent) + Timber.v("Intent extras: %s", intent.extras) + Timber.v("Intent data: %s", intent.data) + if (intent.getStringParam("type") != null) { + return getDestinationFromChannel(intent)?.let { IntentResult.Target(it) } + ?: IntentResult.Error("Invalid parameters") + } + val action = intent.action ?: intent.data?.host + if (action == Intent.ACTION_MAIN || action.isNullOrBlank()) { + return IntentResult.NoOp + } + + val userId = intent.getStringParam("userId")?.toUUIDOrNull() + val serverId = intent.getStringParam("serverId")?.toUUIDOrNull() + if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = + serverRepository.serverDao.getUser(serverId, userId) + ?: return IntentResult.Error("User not found") + if (!user.isProtected) { + serverRepository.restoreSession(serverId, userId) + } else { + return IntentResult.Error("Cannot switch to specified user") + } + } else if (serverRepository.currentUser == null) { + Timber.v("No current user, so restoring last") + val appPrefs = userPreferencesService.flow.first().appPreferences + if (appPrefs.signInAutomatically) { + val userId = appPrefs.currentUserId.toUUIDOrNull() + val serverId = appPrefs.currentServerId.toUUIDOrNull() + if (userId != null && serverId != null) { + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + } else { + return IntentResult.Error("Could not auto sign-in, specify a server & user") + } + } else { + return IntentResult.Error("Auto sign-in not enabled, specify a server & user") + } + } + + if (action == Intent.ACTION_SEARCH || action == "search") { + val query = intent.getStringParam(SearchManager.QUERY) + return IntentResult.Target(Destination.Search(query ?: "")) + } + val itemId = + intent.getStringParam("itemId")?.toUUIDOrNull() + ?: return IntentResult.Error("No item id provided") + + 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") + } + + val destination = + when (action) { + Intent.ACTION_VIEW, "view" -> { + item.destination() + } + + "com.github.damontecres.wholphin.PLAYBACK", "play" -> { + val position = intent.getLongParam("position")?.coerceAtLeast(0) + Destination.Playback( + itemId = itemId, + positionMs = position ?: 0L, + ) + } + + else -> { + return IntentResult.Error("Invalid action: ${intent.action}") + } + } + return IntentResult.Target(destination) + } + + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) + + private fun Intent.getLongParam(key: String) = + getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull() + + private fun getDestinationFromChannel(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } + } + +sealed interface IntentResult { + data class Error( + val message: String, + ) : IntentResult + + data object NoOp : IntentResult + + data class Target( + val destination: Destination, + ) : IntentResult +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 0df2193b..d3dbf4c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted @@ -226,27 +227,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } @@ -363,27 +364,27 @@ class TvProviderWorker setIntent( Intent(context, MainActivity::class.java) - .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) - .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) + .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) .apply { if (item.type == BaseItemKind.EPISODE) { putExtra( - MainActivity.INTENT_SERIES_ID, + IntentService.INTENT_SERIES_ID, dto.seriesId?.toString(), ) putExtra( - MainActivity.INTENT_SEASON_ID, + IntentService.INTENT_SEASON_ID, dto.seasonId?.toString(), ) dto.parentIndexNumber?.let { putExtra( - MainActivity.INTENT_SEASON_NUMBER, + IntentService.INTENT_SEASON_NUMBER, it, ) } dto.indexNumber?.let { putExtra( - MainActivity.INTENT_EPISODE_NUMBER, + IntentService.INTENT_EPISODE_NUMBER, it, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 50c2cc6a..2b54f693 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -496,6 +496,7 @@ private const val COMBINED_ROW = TAB_ROW + 1 @Composable fun SearchPage( + initialQuery: String, userPreferences: UserPreferences, modifier: Modifier = Modifier, viewModel: SearchViewModel = hiltViewModel(), @@ -513,7 +514,7 @@ fun SearchPage( val voiceSearchButtonVisible = prefs.showVoiceSearchButton // val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf("") } + var query by rememberSaveable { mutableStateOf(initialQuery) } val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } val seerrActive by viewModel.seerrActive.collectAsState(initial = false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index accfd693..2468cd78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -54,10 +54,11 @@ sealed class Destination( ) : Destination(true) @Serializable - data object UserAppPreferences : Destination(true) + data class Search( + val query: String = "", + ) : Destination() - @Serializable - data object Search : Destination() + data object UserAppPreferences : Destination(true) @Serializable data class SeriesOverview( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index e0f89304..d7756161 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -351,9 +351,10 @@ fun DestinationContent( LicenseInfo(modifier) } - Destination.Search -> { + is Destination.Search -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } SearchPage( + initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 67fb849d..19f027a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -409,7 +409,7 @@ fun NavDrawer( interactionSource = interactionSource, onClick = { viewModel.setIndex(SEARCH_INDEX) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) }, modifier = Modifier