feat: adding deeplinks for addons and detailscreen

This commit is contained in:
tapframe 2026-07-08 13:37:03 +05:30
parent deddcc6744
commit 3a61117041
7 changed files with 246 additions and 14 deletions

View file

@ -42,6 +42,22 @@
android:host="auth"
android:path="/trakt" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nuvio" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="stremio" />
</intent-filter>
</activity>
<receiver

View file

@ -111,6 +111,7 @@ import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddAddonResult
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
@ -1057,6 +1058,27 @@ private fun MainAppContent(
AppDeepLinkRepository.markConsumed(deepLink)
}
is AppDeepLink.AddonInstall -> {
selectedTab = AppScreenTab.Settings
navController.navigate(AddonsSettingsRoute) {
launchSingleTop = true
}
NuvioToastController.show(getString(Res.string.addons_modal_checking_title))
AddonRepository.initialize()
when (val result = AddonRepository.addAddon(deepLink.manifestUrl)) {
is AddAddonResult.Success -> {
NuvioToastController.show(
getString(Res.string.addons_modal_success_message, result.manifest.name),
)
}
is AddAddonResult.Error -> {
NuvioToastController.show(result.message)
}
}
AppDeepLinkRepository.markConsumed(deepLink)
}
AppDeepLink.Downloads -> {
selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) {

View file

@ -7,16 +7,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AppDeepLink {
internal sealed interface AppDeepLink {
data class Meta(
val type: String,
val id: String,
) : AppDeepLink
data class AddonInstall(
val manifestUrl: String,
) : AppDeepLink
data object Downloads : AppDeepLink
}
object AppDeepLinkRepository {
internal object AppDeepLinkRepository {
private val _pendingDeepLink = MutableStateFlow<AppDeepLink?>(null)
val pendingDeepLink: StateFlow<AppDeepLink?> = _pendingDeepLink.asStateFlow()
@ -53,19 +57,118 @@ fun buildMetaDeepLinkUrl(
fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads"
private fun parseAppDeepLink(url: String): AppDeepLink? {
internal fun parseAppDeepLink(url: String): AppDeepLink? {
val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null
if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null
val scheme = parsedUrl.protocol.name.lowercase()
if (scheme == "stremio") {
return if (looksLikeAddonHost(parsedUrl.host.lowercase())) {
customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall)
} else {
null
}
}
if (scheme != "nuvio") return null
return when (parsedUrl.host.lowercase()) {
val host = parsedUrl.host.lowercase()
val pathSegments = parsedUrl.pathSegments.map(String::trim).filter(String::isNotBlank)
return when (host) {
"meta" -> {
val type = parsedUrl.parameters["type"]?.trim().orEmpty()
val id = parsedUrl.parameters["id"]?.trim().orEmpty()
parseMetaFromParameters(parsedUrl)
?: parseMetaFromPath(pathSegments)
}
"detail", "details", "open", "watch" -> parseMetaFromPath(pathSegments)
"movie", "movies", "series", "show", "shows", "tv" -> {
val type = normalizeDeepLinkMediaType(host)
val id = pathSegments.firstOrNull()?.let(::normalizeDeepLinkId).orEmpty()
if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
"imdb", "tmdb" -> parseProviderMetaDeepLink(host, pathSegments, parsedUrl)
"downloads" -> AppDeepLink.Downloads
else -> null
"auth" -> null
else -> {
if (looksLikeAddonHost(host)) {
customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall)
} else {
null
}
}
}
}
}
private fun parseMetaFromParameters(parsedUrl: Url): AppDeepLink.Meta? {
val type = firstParameter(parsedUrl, "type", "mediaType", "media_type")
?.let(::normalizeDeepLinkMediaType)
.orEmpty()
val id = firstParameter(parsedUrl, "id", "imdb", "imdbId", "imdb_id")
?.let(::normalizeDeepLinkId)
?: firstParameter(parsedUrl, "tmdb", "tmdbId", "tmdb_id")
?.let { "tmdb:${it.removePrefix("tmdb:").trim()}" }
?: ""
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
private fun parseMetaFromPath(pathSegments: List<String>): AppDeepLink.Meta? {
if (pathSegments.size < 2) return null
val type = normalizeDeepLinkMediaType(pathSegments[0])
val id = normalizeDeepLinkId(pathSegments[1])
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
private fun parseProviderMetaDeepLink(
provider: String,
pathSegments: List<String>,
parsedUrl: Url,
): AppDeepLink.Meta? {
val first = pathSegments.firstOrNull().orEmpty()
val second = pathSegments.getOrNull(1).orEmpty()
val firstAsType = normalizeDeepLinkMediaType(first)
val queryType = firstParameter(parsedUrl, "type", "mediaType", "media_type")
?.let(::normalizeDeepLinkMediaType)
.orEmpty()
val type = firstAsType.ifBlank { queryType }
val rawId = if (firstAsType.isNotBlank()) second else first
val id = when (provider) {
"tmdb" -> rawId.removePrefix("tmdb:").trim().takeIf(String::isNotBlank)?.let { "tmdb:$it" }
else -> normalizeDeepLinkId(rawId).takeIf(String::isNotBlank)
}.orEmpty()
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
private fun firstParameter(parsedUrl: Url, vararg keys: String): String? =
keys.firstNotNullOfOrNull { key ->
parsedUrl.parameters[key]?.trim()?.takeIf(String::isNotBlank)
}
private fun normalizeDeepLinkMediaType(value: String): String =
when (value.trim().lowercase()) {
"movie", "movies", "film", "films" -> "movie"
"series", "show", "shows", "tv", "tvshow", "tvshows" -> "series"
else -> ""
}
private fun normalizeDeepLinkId(value: String): String =
value.trim()
.removePrefix("imdb:")
.takeIf(String::isNotBlank)
.orEmpty()
private fun looksLikeAddonHost(host: String): Boolean =
host.contains('.') ||
host.equals("localhost", ignoreCase = true) ||
host.any(Char::isDigit)
private fun customSchemeToHttpsUrl(url: String, scheme: String): String? {
val prefix = "$scheme://"
val rest = url.trim()
.takeIf { it.startsWith(prefix, ignoreCase = true) }
?.substring(prefix.length)
?.takeIf { it.isNotBlank() && !it.startsWith("/") }
?: return null
return "https://$rest"
}

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@ -113,7 +114,7 @@ object MetaDetailsRepository {
scope.launch {
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
val manifests = findReadyMetaManifests(type = type, id = metaLookupId)
if (manifests.isEmpty()) {
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
@ -197,7 +198,7 @@ object MetaDetailsRepository {
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
val manifests = findReadyMetaManifests(type = type, id = metaLookupId)
for (manifest in manifests) {
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
@ -215,6 +216,7 @@ object MetaDetailsRepository {
}
private const val FETCH_TIMEOUT_MS = 5_000L
private const val METADATA_PROVIDER_READY_TIMEOUT_MS = 10_000L
private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L
private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L
@ -269,8 +271,27 @@ object MetaDetailsRepository {
}
}
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
AddonRepository.uiState.value.addons
private suspend fun findReadyMetaManifests(type: String, id: String): List<AddonManifest> {
AddonRepository.initialize()
findMetaManifests(AddonRepository.uiState.value, type, id).takeIf { it.isNotEmpty() }?.let { return it }
if (!AddonRepository.uiState.value.hasPendingEnabledAddonManifests()) {
return emptyList()
}
val readyState = withTimeoutOrNull(METADATA_PROVIDER_READY_TIMEOUT_MS) {
AddonRepository.uiState.first { state ->
findMetaManifests(state, type, id).isNotEmpty() ||
!state.hasPendingEnabledAddonManifests()
}
} ?: AddonRepository.uiState.value
return findMetaManifests(readyState, type, id)
}
private fun findMetaManifests(state: com.nuvio.app.features.addons.AddonsUiState, type: String, id: String): List<AddonManifest> =
state.addons
.enabledAddons()
.mapNotNull { it.manifest }
.filter { manifest ->
@ -281,6 +302,9 @@ object MetaDetailsRepository {
}
}
private fun com.nuvio.app.features.addons.AddonsUiState.hasPendingEnabledAddonManifests(): Boolean =
addons.enabledAddons().any { addon -> addon.manifest == null && addon.isRefreshing }
private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String {
val tmdbId = itemId
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }

View file

@ -129,7 +129,7 @@ object PlayerSubtitleCueParser {
}
private fun parseTtml(text: String): List<SubtitleSyncCue> =
Regex("""<p\b([^>]*)>(.*?)</p>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
Regex("""<p\b([^>]*)>([\s\S]*?)</p>""", RegexOption.IGNORE_CASE)
.findAll(text)
.mapNotNull { match ->
val attrs = match.groupValues[1]

View file

@ -0,0 +1,66 @@
package com.nuvio.app.core.deeplink
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AppUrlBridgeTest {
@Test
fun `parses existing notification meta deeplink`() {
assertEquals(
AppDeepLink.Meta(type = "series", id = "tt0944947"),
parseAppDeepLink("nuvio://meta?type=series&id=tt0944947"),
)
}
@Test
fun `parses direct nuvio addon install deeplink`() {
assertEquals(
AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"),
parseAppDeepLink("nuvio://free.nebulapro.xyz/sports/i/free/manifest.json"),
)
}
@Test
fun `parses stremio addon install deeplink`() {
assertEquals(
AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"),
parseAppDeepLink("stremio://free.nebulapro.xyz/sports/i/free/manifest.json"),
)
}
@Test
fun `parses direct imdb detail deeplink`() {
assertEquals(
AppDeepLink.Meta(type = "series", id = "tt0944947"),
parseAppDeepLink("nuvio://series/tt0944947"),
)
}
@Test
fun `parses provider imdb detail deeplink`() {
assertEquals(
AppDeepLink.Meta(type = "series", id = "tt0944947"),
parseAppDeepLink("nuvio://imdb/series/tt0944947"),
)
}
@Test
fun `parses provider tmdb detail deeplink`() {
assertEquals(
AppDeepLink.Meta(type = "series", id = "tmdb:1399"),
parseAppDeepLink("nuvio://tmdb/tv/1399"),
)
}
@Test
fun `does not treat reserved auth link as addon install`() {
assertNull(parseAppDeepLink("nuvio://auth/trakt?code=abc"))
}
@Test
fun `does not treat non-host stremio link as addon install`() {
assertNull(parseAppDeepLink("stremio://detail/series/tt0944947"))
}
}

View file

@ -14,6 +14,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>stremio</string>
</array>
</dict>
</array>