Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Custom Scheme: arvio://install-pack?url=... -->
<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="arvio" android:host="install-pack" />
</intent-filter>



</activity>

<!-- Crash Report Activity -->
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class MainActivity : ComponentActivity() {

private var jankStats: JankStats? = null
private var pendingLauncherRequest by mutableStateOf<LauncherContinueWatchingRequest?>(null)
private var pendingInstallPackUrl by mutableStateOf<String?>(null)

// StartupViewModel for parallel loading during splash
private val startupViewModel: StartupViewModel by viewModels()
Expand Down Expand Up @@ -204,6 +205,7 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
pendingLauncherRequest = parseLauncherRequest(intent)
pendingInstallPackUrl = parseInstallPackUrl(intent)

val crashPrefs = getSharedPreferences("arvio_crash_store", Context.MODE_PRIVATE)
if (crashPrefs.getBoolean("has_pending_crash_report", false)) {
Expand Down Expand Up @@ -356,6 +358,8 @@ class MainActivity : ComponentActivity() {
skipProfileSelection = skipProfileSelection,
pendingLauncherRequest = pendingLauncherRequest,
onConsumeLauncherRequest = { pendingLauncherRequest = null },
pendingInstallPackUrl = pendingInstallPackUrl,
onConsumeInstallPackUrl = { pendingInstallPackUrl = null },
preloadedCategories = startupState.categories,
preloadedHeroItem = startupState.heroItem,
preloadedHeroLogoUrl = startupState.heroLogoUrl,
Expand Down Expand Up @@ -394,6 +398,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
pendingLauncherRequest = parseLauncherRequest(intent)
pendingInstallPackUrl = parseInstallPackUrl(intent)
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
Expand Down Expand Up @@ -421,6 +426,19 @@ private fun MainActivity.parseLauncherRequest(intent: android.content.Intent?):
return intent?.data?.toLauncherContinueWatchingRequest()
}

private fun MainActivity.parseInstallPackUrl(intent: android.content.Intent?): String? {
val data = intent?.data ?: return null
val scheme = data.scheme ?: return null
val host = data.host ?: return null
return if (scheme == "arvio" && host == "install-pack") {
data.getQueryParameter("url")
} else if ((scheme == "http" || scheme == "https") && host == "arvio.app" && data.path?.startsWith("/install-pack") == true) {
data.getQueryParameter("url")
} else {
null
}
}

private fun ComponentActivity.runAfterFirstDraw(block: () -> Unit) {
val content = window.decorView
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
Expand Down Expand Up @@ -541,6 +559,8 @@ fun ArflixApp(
skipProfileSelection: Boolean? = null,
pendingLauncherRequest: LauncherContinueWatchingRequest? = null,
onConsumeLauncherRequest: () -> Unit = {},
pendingInstallPackUrl: String? = null,
onConsumeInstallPackUrl: () -> Unit = {},
preloadedCategories: List<com.arflix.tv.data.model.Category> = emptyList(),
preloadedHeroItem: com.arflix.tv.data.model.MediaItem? = null,
preloadedHeroLogoUrl: String? = null,
Expand Down Expand Up @@ -690,6 +710,19 @@ fun ArflixApp(
}
onConsumeLauncherRequest()
}

LaunchedEffect(activeProfile?.id, pendingInstallPackUrl) {
val packUrl = pendingInstallPackUrl ?: return@LaunchedEffect
if (activeProfile == null) return@LaunchedEffect

val encodedUrl = java.net.URLEncoder.encode(packUrl, "UTF-8")
val route = "settings?initialSection=catalogs&installPackUrl=$encodedUrl"
navController.navigate(route) {
popUpTo(Screen.ProfileSelection.route) { inclusive = true }
launchSingleTop = true
}
onConsumeInstallPackUrl()
}
}

private fun enqueueFullTraktSync(context: android.content.Context) {
Expand Down
45 changes: 44 additions & 1 deletion app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,38 @@ data class CatalogConfig(
val collectionTileShape: CollectionTileShape = CollectionTileShape.LANDSCAPE,
val collectionHideTitle: Boolean = false,
val collectionSources: List<CollectionSourceConfig> = emptyList(),
val requiredAddonUrls: List<String> = emptyList()
val requiredAddonUrls: List<String> = emptyList(),
val packId: String? = null,
val packName: String? = null
) : Serializable

val CatalogConfig.effectivePackId: String
get() = packId ?: when (sourceType) {
CatalogSourceType.PREINSTALLED -> "system"
CatalogSourceType.ADDON -> "addon"
CatalogSourceType.TRAKT -> "trakt"
CatalogSourceType.MDBLIST -> "mdblist"
CatalogSourceType.HOME_SERVER -> "home_server"
}

val CatalogConfig.effectivePackName: String
get() = packName ?: when (sourceType) {
CatalogSourceType.PREINSTALLED -> "System Catalogs"
CatalogSourceType.ADDON -> "Addon Catalogs"
CatalogSourceType.TRAKT -> "Trakt Catalogs"
CatalogSourceType.MDBLIST -> "MDBlist Catalogs"
CatalogSourceType.HOME_SERVER -> "Home Server Catalogs"
}

val CatalogConfig.isBulkDeletablePack: Boolean
get() = packId != null &&
packId != "system" &&
packId != "addon" &&
packId != "trakt" &&
packId != "mdblist" &&
packId != "home_server" &&
packId != "individual"

data class CatalogDiscoveryResult(
val id: String,
val title: String,
Expand All @@ -123,3 +152,17 @@ data class CatalogValidationResult(
val sourceType: CatalogSourceType? = null,
val error: String? = null
)

data class CatalogPackManifest(
val id: String?,
val name: String?,
val author: String? = null,
val version: String? = null,
val description: String? = null,
val catalogs: List<CatalogPackItem>?
) : Serializable

data class CatalogPackItem(
val name: String?,
val url: String?
) : Serializable
116 changes: 112 additions & 4 deletions app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import com.arflix.tv.data.model.AddonCatalog
import com.arflix.tv.data.model.AddonType
import com.arflix.tv.data.model.CatalogConfig
import com.arflix.tv.data.model.CatalogKind
import com.arflix.tv.data.model.CatalogPackManifest
import com.arflix.tv.data.model.CatalogPackItem
import com.arflix.tv.data.model.CollectionGroupKind
import com.arflix.tv.data.model.CollectionSourceConfig
import com.arflix.tv.data.model.CollectionTileShape
Expand Down Expand Up @@ -706,6 +708,112 @@ class CatalogRepository @Inject constructor(
return true
}

suspend fun fetchCatalogPackManifest(packUrl: String): Result<CatalogPackManifest> {
val trimmed = packUrl.trim()
if (!trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) {
return Result.failure(IllegalArgumentException("Invalid URL: must start with http:// or https://"))
}

val json = fetchUrl(trimmed)
?: return Result.failure(IllegalArgumentException("Failed to fetch catalog pack from URL"))
val manifest = try {
val type = object : com.google.gson.reflect.TypeToken<CatalogPackManifest>() {}.type
gson.fromJson<CatalogPackManifest>(json, type)
} catch (e: Exception) {
null
} ?: return Result.failure(IllegalArgumentException("Failed to parse catalog pack manifest"))

if (manifest.id.isNullOrBlank() || manifest.name.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: missing ID or Name"))
}
val cats = manifest.catalogs
if (cats.isNullOrEmpty()) {
return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: catalogs list is empty or missing"))
}
val normalizedUrls = mutableSetOf<String>()
for (item in cats) {
if (item.name.isNullOrBlank() || item.url.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: catalog items must have a name and url"))
}
val normalized = CatalogUrlParser.normalize(item.url).lowercase()
if (!normalizedUrls.add(normalized)) {
return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: duplicate catalog URL '$normalized'"))
}
}
return Result.success(manifest)
}

suspend fun addCatalogPack(packUrl: String, manifest: CatalogPackManifest? = null): Result<CatalogPackManifest> {
val finalManifest = if (manifest != null) {
manifest
} else {
val manifestResult = fetchCatalogPackManifest(packUrl)
if (manifestResult.isFailure) return manifestResult
manifestResult.getOrThrow()
}

val current = getCatalogs().toMutableList()
val addedConfigs = mutableListOf<CatalogConfig>()

val manifestId = finalManifest.id ?: return Result.failure(IllegalArgumentException("Manifest missing ID"))
val manifestName = finalManifest.name ?: return Result.failure(IllegalArgumentException("Manifest missing Name"))
val manifestCatalogs = finalManifest.catalogs ?: return Result.failure(IllegalArgumentException("Manifest missing catalogs"))

val normalizedPackUrl = CatalogUrlParser.normalize(packUrl).lowercase()
val derivedPackId = "pack_${sha256Short(normalizedPackUrl + "|" + manifestId)}"

for (item in manifestCatalogs) {
val itemUrl = item.url ?: continue
val validation = validateCatalogUrl(itemUrl)
if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) {
continue
}
val normalizedUrl = validation.normalizedUrl
val sourceType = validation.sourceType

// Skip if this URL is already added
if (current.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
continue
}

val resolved = resolveMetadata(normalizedUrl, sourceType)
?: fallbackMetadata(normalizedUrl, sourceType)
?: continue

val stableId = "custom_${sha256Short(derivedPackId + "|" + normalizedUrl)}"
val newCatalog = CatalogConfig(
id = stableId,
title = item.name?.trim()?.ifBlank { resolved.title } ?: resolved.title,
sourceType = sourceType,
sourceUrl = normalizedUrl,
sourceRef = resolved.sourceRef,
isPreinstalled = false,
packId = derivedPackId,
packName = manifestName
)
addedConfigs.add(newCatalog)
}

if (addedConfigs.isEmpty()) {
return Result.failure(IllegalArgumentException("No new or valid catalogs found in this pack to import"))
}

current.addAll(0, addedConfigs)
saveCatalogs(current)
return Result.success(finalManifest)
}

suspend fun removeCatalogPack(packId: String): Result<Unit> {
val current = getCatalogs().toMutableList()
val beforeSize = current.size
current.removeAll { it.packId == packId }
if (current.size == beforeSize) {
return Result.failure(IllegalArgumentException("No catalogs found for pack: $packId"))
}
saveCatalogs(current)
return Result.success(Unit)
}

suspend fun addCustomCatalog(rawUrl: String): Result<CatalogConfig> {
val validation = validateCatalogUrl(rawUrl)
if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) {
Expand Down Expand Up @@ -1019,11 +1127,11 @@ class CatalogRepository @Inject constructor(

private suspend fun fetchUrl(url: String): String? {
return withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(url)
.header("User-Agent", OkHttpProvider.userAgentOr("Mozilla/5.0 (Android TV; ARVIO)"))
.build()
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", OkHttpProvider.userAgentOr("Mozilla/5.0 (Android TV; ARVIO)"))
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ fun AppNavigation(

// Settings screen
composable(
route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}",
route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}&installPackUrl={installPackUrl}",
arguments = listOf(
navArgument("autoCloudAuth") {
type = NavType.BoolType
Expand All @@ -300,15 +300,22 @@ fun AppNavigation(
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("installPackUrl") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val autoCloudAuth = backStackEntry.arguments?.getBoolean("autoCloudAuth") ?: false
val initialSection = backStackEntry.arguments?.getString("initialSection")
val installPackUrl = backStackEntry.arguments?.getString("installPackUrl")
SettingsScreen(
currentProfile = currentProfile,
autoStartCloudAuth = autoCloudAuth,
initialSection = initialSection,
installPackUrl = installPackUrl,
onNavigateToHome = { navigateHome() },
onNavigateToSearch = { navigateTopLevel(Screen.Search.route) },
onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) },
Expand Down
Loading
Loading