Skip to content
Open
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
12 changes: 0 additions & 12 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ dependencies {
// Bundle real crypto-js (JS) for QuickJS plugins
implementation("org.webjars.npm:crypto-js:4.2.0")

// TV Recommendations (Home Screen Channels)
implementation(libs.androidx.tvprovider)

// WorkManager (periodic recommendation sync)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.hilt.work)
ksp(libs.hilt.work.compiler)

androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("junit:junit:4.13.2")
Expand Down
12 changes: 12 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,15 @@
# Keep line numbers for crash reports
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

# ── TV Recommendations (WorkManager + TvProvider) ─────────────────────────────
# Keep HiltWorker-annotated classes
-keep class com.nuvio.tv.data.worker.** { *; }
# Keep BroadcastReceiver for INITIALIZE_PROGRAMS
-keep class com.nuvio.tv.core.recommendations.RecommendationReceiver { *; }
# Keep TvProvider / TvContractCompat classes
-keep class androidx.tvprovider.** { *; }
-dontwarn androidx.tvprovider.**
# Keep WorkManager + Hilt integration
-keep class androidx.work.** { *; }
-keep class androidx.hilt.work.** { *; }
34 changes: 34 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<uses-feature
Expand Down Expand Up @@ -41,6 +42,19 @@
android:resource="@xml/file_paths" />
</provider>

<!-- Disable default WorkManager initializer because we supply our own
via Configuration.Provider in NuvioApplication (needed for HiltWorkerFactory) -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

<activity
android:name=".MainActivity"
android:exported="true"
Expand All @@ -51,7 +65,27 @@
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>

<!-- Deep link for TV Home Screen Recommendations -->
<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="nuviotv"
android:host="content" />
</intent-filter>
</activity>

<!-- TV Recommendations: system requests channel population after reboot / install -->
<receiver
android:name=".core.recommendations.RecommendationReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
</application>

</manifest>
72 changes: 72 additions & 0 deletions app/src/main/java/com/nuvio/tv/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.nuvio.tv

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.content.Context
import android.content.res.Configuration
Expand Down Expand Up @@ -129,6 +131,7 @@ import kotlinx.coroutines.launch
import coil.compose.rememberAsyncImagePainter
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import com.nuvio.tv.core.recommendations.RecommendationConstants
import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R

Expand Down Expand Up @@ -174,6 +177,10 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appOnboardingDataStore: AppOnboardingDataStore

/** Deep link URI queued before the NavController is ready. */
private var pendingDeepLink = mutableStateOf<Uri?>(null)

@OptIn(ExperimentalTvMaterial3Api::class)
private lateinit var jankStats: JankStats

@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
Expand All @@ -195,6 +202,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
handleRecommendationDeepLink(intent)
setContent {
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -338,6 +346,12 @@ class MainActivity : ComponentActivity() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

// Consume deep link from TV recommendation cards
val deepLinkUri by pendingDeepLink
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
consumeDeepLink(uri, navController)
pendingDeepLink.value = null
val view = LocalView.current
LaunchedEffect(currentRoute) {
val holder = PerformanceMetricsState.getHolderForHierarchy(view)
Expand Down Expand Up @@ -481,6 +495,64 @@ class MainActivity : ComponentActivity() {
traktProgressService.refreshNow()
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleRecommendationDeepLink(intent)
}

/**
* Parses a `nuviotv://content/…` deep link from a TV recommendation card
* and queues it for consumption once the NavController is ready.
*/
private fun handleRecommendationDeepLink(intent: Intent?) {
val uri = intent?.data ?: return
if (uri.scheme != RecommendationConstants.DEEP_LINK_SCHEME) return
pendingDeepLink.value = uri
}

/**
* Processes a queued deep link URI and navigates accordingly.
* Call from a Composable scope that has the NavController.
*/
private fun consumeDeepLink(uri: Uri, navController: NavHostController) {
val pathSegments = uri.pathSegments ?: return
val action = pathSegments.getOrNull(0) ?: return
val contentId = pathSegments.getOrNull(1) ?: return
val contentType = uri.getQueryParameter(RecommendationConstants.PARAM_CONTENT_TYPE) ?: "movie"

when (action) {
RecommendationConstants.DEEP_LINK_PATH_DETAIL -> {
val route = Screen.Detail.createRoute(
itemId = contentId,
itemType = contentType
)
navController.navigate(route)
}
RecommendationConstants.DEEP_LINK_PATH_PLAY -> {
val videoId = uri.getQueryParameter(RecommendationConstants.PARAM_VIDEO_ID) ?: contentId
val season = uri.getQueryParameter(RecommendationConstants.PARAM_SEASON)?.toIntOrNull()
val episode = uri.getQueryParameter(RecommendationConstants.PARAM_EPISODE)?.toIntOrNull()
val name = uri.getQueryParameter(RecommendationConstants.PARAM_NAME)
val poster = uri.getQueryParameter(RecommendationConstants.PARAM_POSTER)
val backdrop = uri.getQueryParameter(RecommendationConstants.PARAM_BACKDROP)

val route = Screen.Stream.createRoute(
videoId = videoId,
contentType = contentType,
title = name ?: contentId,
poster = poster,
backdrop = backdrop,
season = season,
episode = episode,
contentId = contentId,
contentName = name
)
navController.navigate(route)
}
else -> { /* Unknown deep link action */ }
}
}
}

@OptIn(ExperimentalTvMaterial3Api::class)
Expand Down
61 changes: 60 additions & 1 deletion app/src/main/java/com/nuvio/tv/NuvioApplication.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
package com.nuvio.tv

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.nuvio.tv.core.recommendations.RecommendationConstants
import com.nuvio.tv.core.recommendations.TvRecommendationManager
import com.nuvio.tv.core.sync.StartupSyncService
import com.nuvio.tv.data.worker.TvRecommendationWorker
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject

@HiltAndroidApp
class NuvioApplication : Application(), ImageLoaderFactory {
class NuvioApplication : Application(), ImageLoaderFactory, Configuration.Provider {

@Inject lateinit var startupSyncService: StartupSyncService
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var tvRecommendationManager: TvRecommendationManager

private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

override fun onCreate() {
super.onCreate()
initializeTvRecommendations()
}

override fun onCreate() {
super.onCreate()
Expand All @@ -39,4 +67,35 @@ class NuvioApplication : Application(), ImageLoaderFactory {
.crossfade(false)
.build()
}

// ── TV Home Screen Recommendations ──

private fun initializeTvRecommendations() {
// Create channels asynchronously — no-op on non-TV devices
appScope.launch {
try {
tvRecommendationManager.initializeChannels()
} catch (_: Exception) {
}
}

// Schedule periodic background sync
scheduleRecommendationSync()
}

private fun scheduleRecommendationSync() {
val workRequest = PeriodicWorkRequestBuilder<TvRecommendationWorker>(
RecommendationConstants.SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
RecommendationConstants.WORK_NAME_PERIODIC_SYNC,
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
}
Loading