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