feat: notifications init

This commit is contained in:
tapframe 2026-04-04 12:29:25 +05:30
parent 7cc393e6a9
commit 3f1d13191a
32 changed files with 1409 additions and 10 deletions

View file

@ -120,6 +120,7 @@ kotlin {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.work.runtime)
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"

View file

@ -7,12 +7,15 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle
import androidx.core.view.WindowCompat
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.ProfileStorage
@ -21,7 +24,6 @@ import com.nuvio.app.features.search.SearchHistoryStorage
import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage
@ -55,11 +57,14 @@ class MainActivity : ComponentActivity() {
TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
EpisodeReleaseNotificationsStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext)
forwardTraktAuthCallback(intent)
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this)
handleIncomingAppIntent(intent)
setContent {
App()
@ -69,12 +74,28 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
forwardTraktAuthCallback(intent)
handleIncomingAppIntent(intent)
}
private fun forwardTraktAuthCallback(intent: Intent?) {
val callbackUrl = intent?.dataString?.trim().orEmpty()
if (callbackUrl.isBlank()) return
handleTraktAuthCallbackUrl(callbackUrl)
override fun onDestroy() {
EpisodeReleaseNotificationPlatform.unbindActivity(this)
super.onDestroy()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
if (EpisodeReleaseNotificationPlatform.handlePermissionRequestResult(requestCode, grantResults)) {
return
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun handleIncomingAppIntent(intent: Intent?) {
val appUrl = intent?.dataString?.trim().orEmpty()
if (appUrl.isBlank()) return
handleAppUrl(appUrl)
}
}

View file

@ -15,6 +15,8 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
"nuvio_plugins",
)

View file

@ -0,0 +1,245 @@
package com.nuvio.app.features.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkManager
import java.time.LocalDate
import java.time.ZoneId
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
internal actual object EpisodeReleaseNotificationPlatform {
private const val permissionRequestCode = 4607
private const val platformPreferencesName = "nuvio_episode_release_notifications_platform"
private const val scheduledIdsKey = "scheduled_episode_release_ids"
private const val workTag = "episode_release_notifications"
internal const val channelId = "episode_release_notifications"
internal const val workerRequestIdKey = "request_id"
internal const val workerTitleKey = "title"
internal const val workerBodyKey = "body"
internal const val workerDeepLinkKey = "deep_link"
private var appContext: Context? = null
private var currentActivity: ComponentActivity? = null
private var pendingPermissionContinuation: kotlin.coroutines.Continuation<Boolean>? = null
fun initialize(context: Context) {
appContext = context.applicationContext
ensureNotificationChannel()
}
fun bindActivity(activity: ComponentActivity) {
currentActivity = activity
}
fun unbindActivity(activity: ComponentActivity) {
if (currentActivity === activity) {
currentActivity = null
}
}
fun handlePermissionRequestResult(
requestCode: Int,
grantResults: IntArray,
): Boolean {
if (requestCode != permissionRequestCode) return false
val granted = grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
pendingPermissionContinuation?.resume(granted)
pendingPermissionContinuation = null
return true
}
actual suspend fun notificationsAuthorized(): Boolean {
val context = appContext ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionState = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permissionState != PackageManager.PERMISSION_GRANTED) {
return false
}
}
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
actual suspend fun requestAuthorization(): Boolean {
val context = appContext ?: return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
ensureNotificationChannel()
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
val permissionState = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permissionState == PackageManager.PERMISSION_GRANTED) {
ensureNotificationChannel()
return true
}
val activity = currentActivity ?: return false
return suspendCancellableCoroutine { continuation ->
pendingPermissionContinuation = continuation
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
permissionRequestCode,
)
}
}
actual suspend fun scheduleEpisodeReleaseNotifications(requests: List<EpisodeReleaseNotificationRequest>) {
val context = appContext ?: return
ensureNotificationChannel()
withContext(Dispatchers.IO) {
val workManager = WorkManager.getInstance(context)
cancelTrackedWork(workManager)
val nowEpochMs = System.currentTimeMillis()
val scheduledIds = mutableListOf<String>()
requests.forEach { request ->
val triggerAtEpochMs = triggerAtEpochMs(request.releaseDateIso) ?: return@forEach
val initialDelayMs = triggerAtEpochMs - nowEpochMs
if (initialDelayMs <= 0L) return@forEach
val inputData = Data.Builder()
.putString(workerRequestIdKey, request.requestId)
.putString(workerTitleKey, request.notificationTitle)
.putString(workerBodyKey, request.notificationBody)
.putString(workerDeepLinkKey, request.deepLinkUrl)
.build()
val workRequest = OneTimeWorkRequestBuilder<EpisodeReleaseNotificationWorker>()
.setInputData(inputData)
.setInitialDelay(initialDelayMs, TimeUnit.MILLISECONDS)
.addTag(workTag)
.build()
awaitOperation(
workManager.enqueueUniqueWork(
uniqueWorkName(request.requestId),
ExistingWorkPolicy.REPLACE,
workRequest,
),
)
scheduledIds += request.requestId
}
preferences(context)
.edit()
.putStringSet(scheduledIdsKey, scheduledIds.toSet())
.apply()
}
}
actual suspend fun clearScheduledEpisodeReleaseNotifications() {
val context = appContext ?: return
withContext(Dispatchers.IO) {
val workManager = WorkManager.getInstance(context)
cancelTrackedWork(workManager)
preferences(context)
.edit()
.remove(scheduledIdsKey)
.apply()
}
}
actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) {
val context = appContext ?: return
ensureNotificationChannel()
val launchIntent = android.content.Intent(context, com.nuvio.app.MainActivity::class.java).apply {
action = android.content.Intent.ACTION_VIEW
data = android.net.Uri.parse(request.deepLinkUrl)
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = android.app.PendingIntent.getActivity(
context,
kotlin.math.abs(request.requestId.hashCode()),
launchIntent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(com.nuvio.app.R.mipmap.ic_launcher)
.setContentTitle(request.notificationTitle)
.setContentText(request.notificationBody)
.setStyle(NotificationCompat.BigTextStyle().bigText(request.notificationBody))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context)
.notify(kotlin.math.abs(request.requestId.hashCode()), notification)
}
private fun cancelTrackedWork(workManager: WorkManager) {
val context = appContext ?: return
preferences(context)
.getStringSet(scheduledIdsKey, emptySet())
.orEmpty()
.forEach { requestId ->
awaitOperation(workManager.cancelUniqueWork(uniqueWorkName(requestId)))
}
}
private fun awaitOperation(operation: Operation) {
operation.result.get()
}
private fun preferences(context: Context) =
context.getSharedPreferences(platformPreferencesName, Context.MODE_PRIVATE)
private fun triggerAtEpochMs(releaseDateIso: String): Long? = runCatching {
LocalDate.parse(releaseDateIso)
.atTime(EpisodeReleaseNotificationHour, EpisodeReleaseNotificationMinute)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}.getOrNull()
private fun ensureNotificationChannel() {
val context = appContext ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return
val existingChannel = notificationManager.getNotificationChannel(channelId)
if (existingChannel != null) return
val channel = NotificationChannel(
channelId,
"Episode Releases",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Alerts when a saved show's new episode is released."
}
notificationManager.createNotificationChannel(channel)
}
private fun uniqueWorkName(requestId: String): String = "$workTag:$requestId"
}

View file

@ -0,0 +1,59 @@
package com.nuvio.app.features.notifications
import android.app.PendingIntent
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.nuvio.app.MainActivity
import com.nuvio.app.R
import kotlin.math.abs
class EpisodeReleaseNotificationWorker(
appContext: android.content.Context,
workerParameters: WorkerParameters,
) : CoroutineWorker(appContext, workerParameters) {
override suspend fun doWork(): Result {
if (!EpisodeReleaseNotificationPlatform.notificationsAuthorized()) {
return Result.success()
}
val requestId = inputData.getString(EpisodeReleaseNotificationPlatform.workerRequestIdKey)
?: return Result.failure()
val title = inputData.getString(EpisodeReleaseNotificationPlatform.workerTitleKey)
?: return Result.failure()
val body = inputData.getString(EpisodeReleaseNotificationPlatform.workerBodyKey)
?: return Result.failure()
val deepLink = inputData.getString(EpisodeReleaseNotificationPlatform.workerDeepLinkKey)
?: return Result.failure()
val launchIntent = Intent(applicationContext, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
abs(requestId.hashCode()),
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, EpisodeReleaseNotificationPlatform.channelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext)
.notify(abs(requestId.hashCode()), notification)
return Result.success()
}
}

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.notifications
import java.time.Instant
import java.time.ZoneId
internal actual object EpisodeReleaseNotificationsClock {
actual fun isoDateFromEpochMs(epochMs: Long): String =
Instant.ofEpochMilli(epochMs)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.toString()
}

View file

@ -0,0 +1,26 @@
package com.nuvio.app.features.notifications
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object EpisodeReleaseNotificationsStorage {
private const val preferencesName = "nuvio_episode_release_notifications"
private const val payloadKey = "episode_release_notifications_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -1,5 +1,11 @@
package com.nuvio.app.features.trakt
import java.time.Instant
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = System.currentTimeMillis()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
Instant.parse(value).toEpochMilli()
}.getOrNull()
}

View file

@ -68,6 +68,8 @@ import coil3.request.CachePolicy
import coil3.request.crossfade
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets
import com.nuvio.app.core.ui.NuvioPosterActionSheet
@ -93,6 +95,7 @@ import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerRoute
@ -127,6 +130,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.Res
@ -356,6 +360,9 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
@ -382,11 +389,28 @@ private fun MainAppContent(
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
EpisodeReleaseNotificationsRepository.refreshAsync()
kotlinx.coroutines.delay(5_000)
initialHomeReady = true
}
var profileSwitchLoading by remember { mutableStateOf(false) }
LaunchedEffect(navController) {
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
when (deepLink) {
is AppDeepLink.Meta -> {
selectedTab = AppScreenTab.Home
navController.navigate(DetailRoute(type = deepLink.type, id = deepLink.id)) {
launchSingleTop = true
}
AppDeepLinkRepository.markConsumed(deepLink)
}
null -> Unit
}
}
}
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
val streamContextId = pauseDescription

View file

@ -0,0 +1,65 @@
package com.nuvio.app.core.deeplink
import com.nuvio.app.features.trakt.handleTraktAuthCallbackUrl
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AppDeepLink {
data class Meta(
val type: String,
val id: String,
) : AppDeepLink
}
object AppDeepLinkRepository {
private val _pendingDeepLink = MutableStateFlow<AppDeepLink?>(null)
val pendingDeepLink: StateFlow<AppDeepLink?> = _pendingDeepLink.asStateFlow()
fun handleUrl(url: String) {
parseAppDeepLink(url)?.let { deepLink ->
_pendingDeepLink.value = deepLink
}
}
fun markConsumed(deepLink: AppDeepLink) {
if (_pendingDeepLink.value == deepLink) {
_pendingDeepLink.value = null
}
}
}
fun handleAppUrl(url: String) {
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) return
handleTraktAuthCallbackUrl(normalizedUrl)
AppDeepLinkRepository.handleUrl(normalizedUrl)
}
fun buildMetaDeepLinkUrl(
type: String,
id: String,
): String = buildString {
append("nuvio://meta?type=")
append(type.trim().encodeURLParameter())
append("&id=")
append(id.trim().encodeURLParameter())
}
private fun parseAppDeepLink(url: String): AppDeepLink? {
val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null
if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null
return when (parsedUrl.host.lowercase()) {
"meta" -> {
val type = parsedUrl.parameters["type"]?.trim().orEmpty()
val id = parsedUrl.parameters["id"]?.trim().orEmpty()
if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
}
else -> null
}
}

View file

@ -7,6 +7,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -35,6 +36,7 @@ internal object LocalAccountDataCleaner {
WatchProgressRepository.clearLocalState()
WatchedRepository.clearLocalState()
ContinueWatchingPreferencesRepository.clearLocalState()
EpisodeReleaseNotificationsRepository.clearLocalState()
ThemeSettingsRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.notifications
internal expect object EpisodeReleaseNotificationPlatform {
suspend fun notificationsAuthorized(): Boolean
suspend fun requestAuthorization(): Boolean
suspend fun scheduleEpisodeReleaseNotifications(requests: List<EpisodeReleaseNotificationRequest>)
suspend fun clearScheduledEpisodeReleaseNotifications()
suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest)
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.notifications
internal expect object EpisodeReleaseNotificationsClock {
fun isoDateFromEpochMs(epochMs: Long): String
}

View file

@ -0,0 +1,90 @@
package com.nuvio.app.features.notifications
import kotlinx.serialization.Serializable
import kotlin.math.abs
data class EpisodeReleaseNotificationsUiState(
val isEnabled: Boolean = false,
val isLoading: Boolean = false,
val permissionGranted: Boolean = false,
val scheduledCount: Int = 0,
val testTargetTitle: String? = null,
val isSendingTest: Boolean = false,
val statusMessage: String? = null,
val errorMessage: String? = null,
)
@Serializable
internal data class StoredEpisodeReleaseNotificationsPayload(
val enabled: Boolean = false,
val followedShows: List<TrackedFollowedShow> = emptyList(),
)
@Serializable
internal data class TrackedFollowedShow(
val contentId: String,
val contentType: String,
val followedOnIsoDate: String,
)
internal data class EpisodeReleaseNotificationRequest(
val requestId: String,
val notificationTitle: String,
val notificationBody: String,
val releaseDateIso: String,
val deepLinkUrl: String,
)
internal const val EpisodeReleaseNotificationHour = 9
internal const val EpisodeReleaseNotificationMinute = 0
internal const val MinReasonableSavedAtEpochMs = 946684800000L
internal fun buildTrackedShowKey(
type: String,
id: String,
): String = "${normalizeSeriesType(type)}:${id.trim()}"
internal fun normalizeSeriesType(type: String): String = when (type.trim().lowercase()) {
"tv", "show", "series", "tvshow" -> "series"
else -> type.trim().lowercase()
}
internal fun isSeriesLibraryType(type: String): Boolean = normalizeSeriesType(type) == "series"
internal fun releaseDateIso(rawValue: String?): String? {
val value = rawValue
?.substringBefore('T')
?.trim()
.orEmpty()
return value.takeIf { it.length == 10 }
}
internal fun buildEpisodeReleaseNotificationId(
profileId: Int,
contentType: String,
contentId: String,
episodeId: String,
releaseDateIso: String,
): String {
val contentHash = abs(buildTrackedShowKey(contentType, contentId).hashCode())
val episodeHash = abs(episodeId.trim().ifBlank { releaseDateIso }.hashCode())
return "episode-release-$profileId-$contentHash-$episodeHash-$releaseDateIso"
}
internal fun buildEpisodeReleaseNotificationBody(
seasonNumber: Int?,
episodeNumber: Int?,
episodeTitle: String?,
): String {
val seasonLabel = seasonNumber?.let { season -> "S${season.toString().padStart(2, '0')}" }
val episodeLabel = episodeNumber?.let { episode -> "E${episode.toString().padStart(2, '0')}" }
val code = listOfNotNull(seasonLabel, episodeLabel).joinToString(separator = "")
val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() }
return when {
code.isNotBlank() && title != null -> "$code$title is out now"
code.isNotBlank() -> "$code is out now"
title != null -> "$title is out now"
else -> "A new episode is out now"
}
}

View file

@ -0,0 +1,448 @@
package com.nuvio.app.features.notifications
import co.touchlab.kermit.Logger
import com.nuvio.app.core.deeplink.buildMetaDeepLinkUrl
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibraryUiState
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object EpisodeReleaseNotificationsRepository {
private const val metadataFetchConcurrency = 4
private const val testNotificationDelaySeconds = 1L
private val log = Logger.withTag("EpisodeReleaseNotifications")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val refreshMutex = Mutex()
private val _uiState = MutableStateFlow(EpisodeReleaseNotificationsUiState())
val uiState: StateFlow<EpisodeReleaseNotificationsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
private var trackedShowsByKey: MutableMap<String, TrackedFollowedShow> = mutableMapOf()
init {
scope.launch {
LibraryRepository.uiState.collectLatest { state ->
if (!hasLoaded) return@collectLatest
val changed = reconcileTrackedShows(state)
if (changed) {
persist()
}
if (_uiState.value.isEnabled) {
refreshScheduledNotifications()
}
}
}
}
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
scope.launch {
syncAuthorizationState(refreshIfEnabled = true)
}
}
fun onProfileChanged() {
loadFromDisk()
scope.launch {
syncAuthorizationState(refreshIfEnabled = true)
}
}
fun clearLocalState() {
hasLoaded = false
trackedShowsByKey.clear()
_uiState.value = EpisodeReleaseNotificationsUiState()
scope.launch {
runCatching { EpisodeReleaseNotificationPlatform.clearScheduledEpisodeReleaseNotifications() }
.onFailure { error ->
log.w { "Failed to clear scheduled episode release notifications: ${error.message}" }
}
}
}
fun setEnabled(enabled: Boolean) {
ensureLoaded()
scope.launch {
if (!enabled) {
runCatching { EpisodeReleaseNotificationPlatform.clearScheduledEpisodeReleaseNotifications() }
.onFailure { error ->
log.w { "Failed to clear episode release notifications: ${error.message}" }
}
_uiState.value = _uiState.value.copy(
isEnabled = false,
isLoading = false,
scheduledCount = 0,
statusMessage = null,
errorMessage = null,
)
persist()
return@launch
}
_uiState.value = _uiState.value.copy(
isLoading = true,
errorMessage = null,
)
val granted = runCatching { EpisodeReleaseNotificationPlatform.requestAuthorization() }
.onFailure { error ->
log.e(error) { "Failed to request episode release notification permission" }
}
.getOrDefault(false)
if (!granted) {
_uiState.value = _uiState.value.copy(
isEnabled = false,
isLoading = false,
permissionGranted = false,
scheduledCount = 0,
statusMessage = null,
errorMessage = "Notifications permission is disabled for Nuvio.",
)
persist()
return@launch
}
_uiState.value = _uiState.value.copy(
isEnabled = true,
isLoading = false,
permissionGranted = true,
statusMessage = null,
errorMessage = null,
)
persist()
refreshScheduledNotifications()
}
}
fun sendTestNotification() {
ensureLoaded()
scope.launch {
val target = currentTestTarget()
if (target == null) {
_uiState.value = _uiState.value.copy(
isSendingTest = false,
statusMessage = null,
errorMessage = "Save a show to your library first to test a deeplink notification.",
)
return@launch
}
_uiState.value = _uiState.value.copy(
isSendingTest = true,
statusMessage = null,
errorMessage = null,
)
val granted = runCatching { EpisodeReleaseNotificationPlatform.requestAuthorization() }
.onFailure { error ->
log.e(error) { "Failed to request permission for test notification" }
}
.getOrDefault(false)
if (!granted) {
_uiState.value = _uiState.value.copy(
isSendingTest = false,
permissionGranted = false,
statusMessage = null,
errorMessage = "Notifications permission is disabled for Nuvio.",
)
return@launch
}
val request = EpisodeReleaseNotificationRequest(
requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}",
notificationTitle = target.name,
notificationBody = "Test notification from Nuvio. Tap to open ${target.name}.",
releaseDateIso = CurrentDateProvider.todayIsoDate(),
deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id),
)
runCatching {
EpisodeReleaseNotificationPlatform.showTestNotification(request)
}.onFailure { error ->
log.e(error) { "Failed to send test notification" }
}.onSuccess {
_uiState.value = _uiState.value.copy(
isSendingTest = false,
permissionGranted = true,
statusMessage = "Test notification sent for ${target.name}.",
errorMessage = null,
)
}.onFailure {
_uiState.value = _uiState.value.copy(
isSendingTest = false,
permissionGranted = true,
statusMessage = null,
errorMessage = "Failed to send a test notification.",
)
}
}
}
fun refreshAsync() {
ensureLoaded()
scope.launch {
refreshScheduledNotifications()
}
}
private fun loadFromDisk() {
hasLoaded = true
trackedShowsByKey.clear()
val payload = EpisodeReleaseNotificationsStorage.loadPayload().orEmpty().trim()
val stored = payload.takeIf { it.isNotEmpty() }
?.let { rawPayload ->
runCatching {
json.decodeFromString<StoredEpisodeReleaseNotificationsPayload>(rawPayload)
}.onFailure { error ->
log.w { "Failed to decode episode release notifications payload: ${error.message}" }
}.getOrNull()
}
stored?.followedShows
.orEmpty()
.forEach { trackedShow ->
trackedShowsByKey[buildTrackedShowKey(trackedShow.contentType, trackedShow.contentId)] = trackedShow
}
_uiState.value = EpisodeReleaseNotificationsUiState(
isEnabled = stored?.enabled ?: false,
permissionGranted = false,
scheduledCount = 0,
testTargetTitle = null,
errorMessage = null,
)
updateTestTargetState()
}
private fun persist() {
EpisodeReleaseNotificationsStorage.savePayload(
json.encodeToString(
StoredEpisodeReleaseNotificationsPayload(
enabled = _uiState.value.isEnabled,
followedShows = trackedShowsByKey.values
.sortedWith(compareBy(TrackedFollowedShow::contentType, TrackedFollowedShow::contentId)),
),
),
)
}
private suspend fun syncAuthorizationState(refreshIfEnabled: Boolean) {
val granted = runCatching { EpisodeReleaseNotificationPlatform.notificationsAuthorized() }
.onFailure { error ->
log.w { "Failed to read episode release notification permission: ${error.message}" }
}
.getOrDefault(false)
_uiState.value = _uiState.value.copy(
permissionGranted = granted,
testTargetTitle = currentTestTarget()?.name,
errorMessage = when {
_uiState.value.isEnabled && !granted -> "System notifications are currently disabled for Nuvio."
else -> _uiState.value.errorMessage
},
)
if (refreshIfEnabled && _uiState.value.isEnabled) {
refreshScheduledNotifications()
}
}
private fun reconcileTrackedShows(state: LibraryUiState): Boolean {
if (!state.isLoaded) return false
val seriesItems = state.items.filter { item -> isSeriesLibraryType(item.type) }
val nextTrackedShows = linkedMapOf<String, TrackedFollowedShow>()
seriesItems.forEach { item ->
val key = buildTrackedShowKey(item.type, item.id)
nextTrackedShows[key] = trackedShowsByKey[key]
?: TrackedFollowedShow(
contentId = item.id,
contentType = item.type,
followedOnIsoDate = inferFollowedOnIsoDate(item),
)
}
val changed = nextTrackedShows != trackedShowsByKey
if (changed) {
trackedShowsByKey = nextTrackedShows.toMutableMap()
}
updateTestTargetState()
return changed
}
private fun inferFollowedOnIsoDate(item: LibraryItem): String {
if (item.savedAtEpochMs >= MinReasonableSavedAtEpochMs) {
return EpisodeReleaseNotificationsClock.isoDateFromEpochMs(item.savedAtEpochMs)
}
return CurrentDateProvider.todayIsoDate()
}
private suspend fun refreshScheduledNotifications() {
refreshMutex.withLock {
LibraryRepository.ensureLoaded()
val currentLibraryState = LibraryRepository.uiState.value
val trackedShowsChanged = reconcileTrackedShows(currentLibraryState)
if (trackedShowsChanged) {
persist()
}
val permissionGranted = runCatching { EpisodeReleaseNotificationPlatform.notificationsAuthorized() }
.onFailure { error ->
log.w { "Failed to refresh episode release notification permission: ${error.message}" }
}
.getOrDefault(false)
if (!_uiState.value.isEnabled || !permissionGranted) {
runCatching { EpisodeReleaseNotificationPlatform.clearScheduledEpisodeReleaseNotifications() }
.onFailure { error ->
log.w { "Failed to clear scheduled episode release notifications: ${error.message}" }
}
_uiState.value = _uiState.value.copy(
isLoading = false,
permissionGranted = permissionGranted,
scheduledCount = 0,
testTargetTitle = currentTestTarget()?.name,
errorMessage = if (_uiState.value.isEnabled && !permissionGranted) {
"System notifications are currently disabled for Nuvio."
} else {
null
},
)
return
}
_uiState.value = _uiState.value.copy(
isLoading = true,
permissionGranted = true,
errorMessage = null,
)
if (trackedShowsByKey.isEmpty()) {
runCatching { EpisodeReleaseNotificationPlatform.clearScheduledEpisodeReleaseNotifications() }
_uiState.value = _uiState.value.copy(
isLoading = false,
scheduledCount = 0,
testTargetTitle = currentTestTarget()?.name,
errorMessage = null,
)
return
}
AddonRepository.initialize()
withTimeoutOrNull(10_000L) {
AddonRepository.awaitManifestsLoaded()
}
val semaphore = Semaphore(metadataFetchConcurrency)
val requests = trackedShowsByKey.values.map { trackedShow ->
scope.async {
semaphore.withPermit {
buildRequestsForShow(trackedShow)
}
}
}.awaitAll().flatten()
runCatching {
EpisodeReleaseNotificationPlatform.scheduleEpisodeReleaseNotifications(requests)
}.onFailure { error ->
log.e(error) { "Failed to schedule episode release notifications" }
}
_uiState.value = _uiState.value.copy(
isLoading = false,
permissionGranted = true,
scheduledCount = requests.size,
testTargetTitle = currentTestTarget()?.name,
errorMessage = null,
)
}
}
private fun updateTestTargetState() {
_uiState.value = _uiState.value.copy(
testTargetTitle = currentTestTarget()?.name,
)
}
private fun currentTestTarget(): LibraryItem? {
LibraryRepository.ensureLoaded()
val libraryItems = LibraryRepository.uiState.value.items
return libraryItems.firstOrNull { item -> isSeriesLibraryType(item.type) }
?: libraryItems.firstOrNull()
}
private suspend fun buildRequestsForShow(trackedShow: TrackedFollowedShow): List<EpisodeReleaseNotificationRequest> {
val meta = runCatching {
MetaDetailsRepository.fetch(
type = trackedShow.contentType,
id = trackedShow.contentId,
)
}.onFailure { error ->
log.w { "Failed to resolve metadata for ${trackedShow.contentType}:${trackedShow.contentId}: ${error.message}" }
}.getOrNull() ?: return emptyList()
val showTitle = meta.name.ifBlank { trackedShow.contentId }
return meta.videos.mapNotNull { episode ->
val releaseDate = releaseDateIso(episode.released) ?: return@mapNotNull null
if (releaseDate < trackedShow.followedOnIsoDate) return@mapNotNull null
if (episode.season == null && episode.episode == null) return@mapNotNull null
EpisodeReleaseNotificationRequest(
requestId = buildEpisodeReleaseNotificationId(
profileId = ProfileRepository.activeProfileId,
contentType = trackedShow.contentType,
contentId = trackedShow.contentId,
episodeId = episode.id,
releaseDateIso = releaseDate,
),
notificationTitle = showTitle,
notificationBody = buildEpisodeReleaseNotificationBody(
seasonNumber = episode.season,
episodeNumber = episode.episode,
episodeTitle = episode.title,
),
releaseDateIso = releaseDate,
deepLinkUrl = buildMetaDeepLinkUrl(
type = trackedShow.contentType,
id = trackedShow.contentId,
),
)
}
}
}

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.notifications
internal expect object EpisodeReleaseNotificationsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -9,6 +9,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
@ -130,6 +131,7 @@ object ProfileRepository {
HomeCatalogSettingsRepository.onProfileChanged()
MetaScreenSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesRepository.onProfileChanged()
EpisodeReleaseNotificationsRepository.onProfileChanged()
TmdbSettingsRepository.onProfileChanged()
MdbListSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged()

View file

@ -0,0 +1,139 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState
internal fun LazyListScope.notificationsSettingsContent(
isTablet: Boolean,
uiState: EpisodeReleaseNotificationsUiState,
) {
item {
SettingsSection(
title = "ALERTS",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Episode release alerts",
description = "Schedule local notifications when a new episode for a saved show becomes available. Tapping the alert opens that show's meta screen.",
checked = uiState.isEnabled,
enabled = !uiState.isLoading,
isTablet = isTablet,
onCheckedChange = EpisodeReleaseNotificationsRepository::setEnabled,
)
}
}
}
item {
SettingsSection(
title = "TEST",
isTablet = isTablet,
) {
NotificationTestCard(
isTablet = isTablet,
uiState = uiState,
)
}
}
}
@Composable
private fun NotificationTestCard(
isTablet: Boolean,
uiState: EpisodeReleaseNotificationsUiState,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 18.dp else 16.dp
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Dummy notification",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = uiState.testTargetTitle?.let { title ->
"Send a local test notification that deep-links into $title."
} ?: "Save a show to your library first so the test notification has a real meta-screen target.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (uiState.isEnabled) {
"${uiState.scheduledCount} release alerts are currently scheduled on this device."
} else {
"Notifications are currently disabled in Nuvio."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
Button(
onClick = EpisodeReleaseNotificationsRepository::sendTestNotification,
enabled = !uiState.isSendingTest && !uiState.isLoading && uiState.testTargetTitle != null,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text(if (uiState.isSendingTest) "Sending Test Notification..." else "Send Test Notification")
}
uiState.statusMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
uiState.errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
if (!uiState.permissionGranted) {
Text(
text = "System notifications are disabled for Nuvio. Enable them to receive alerts and test notifications.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.ui.graphics.vector.ImageVector
@ -38,6 +39,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Root,
),
Notifications(
title = "Notifications",
category = SettingsCategory.General,
parentPage = Root,
),
ContinueWatching(
title = "Continue Watching",
category = SettingsCategory.General,

View file

@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountCircle
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PlayArrow
@ -13,6 +14,7 @@ internal fun LazyListScope.settingsRootContent(
isTablet: Boolean,
onPlaybackClick: () -> Unit,
onAppearanceClick: () -> Unit,
onNotificationsClick: () -> Unit,
onContentDiscoveryClick: () -> Unit,
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
@ -80,6 +82,14 @@ internal fun LazyListScope.settingsRootContent(
onClick = onAppearanceClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Notifications",
description = "Manage episode release alerts and send a test notification.",
icon = Icons.Rounded.Notifications,
isTablet = isTablet,
onClick = onNotificationsClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Content & Discovery",
description = "Manage addons and discovery sources.",

View file

@ -44,6 +44,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
@ -110,6 +112,10 @@ fun SettingsScreen(
ContinueWatchingPreferencesRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.uiState
}.collectAsStateWithLifecycle()
val episodeReleaseNotificationsUiState by remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
EpisodeReleaseNotificationsRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(addonsUiState.addons) {
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
@ -142,6 +148,7 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
@ -170,6 +177,7 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
@ -208,6 +216,7 @@ private fun MobileSettingsScreen(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
@ -238,6 +247,7 @@ private fun MobileSettingsScreen(
isTablet = false,
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
@ -268,6 +278,10 @@ private fun MobileSettingsScreen(
onAmoledToggle = onAmoledToggle,
onContinueWatchingClick = onContinueWatchingClick,
)
SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = false,
uiState = episodeReleaseNotificationsUiState,
)
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
isTablet = false,
isVisible = continueWatchingPreferencesUiState.isVisible,
@ -333,6 +347,7 @@ private fun TabletSettingsScreen(
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
@ -424,6 +439,7 @@ private fun TabletSettingsScreen(
isTablet = true,
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
@ -456,6 +472,10 @@ private fun TabletSettingsScreen(
onAmoledToggle = onAmoledToggle,
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
)
SettingsPage.Notifications -> notificationsSettingsContent(
isTablet = true,
uiState = episodeReleaseNotificationsUiState,
)
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
isTablet = true,
isVisible = continueWatchingPreferencesUiState.isVisible,

View file

@ -562,7 +562,9 @@ object TraktLibraryRepository {
val banner = media.images?.banner.firstNonBlankImageUrl()
val logo = media.images?.logo.firstNonBlankImageUrl()
val savedAt = item.listedAt?.takeIf { it.isNotBlank() }?.hashCode()?.toLong()?.let { kotlin.math.abs(it) }
val savedAt = item.listedAt
?.takeIf { it.isNotBlank() }
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?: TraktPlatformClock.nowEpochMs()
return LibraryItem(

View file

@ -2,4 +2,5 @@ package com.nuvio.app.features.trakt
internal expect object TraktPlatformClock {
fun nowEpochMs(): Long
fun parseIsoDateTimeToEpochMs(value: String): Long?
}

View file

@ -14,6 +14,8 @@ internal actual object PlatformLocalAccountDataCleaner {
private val profileScopedBaseKeys = listOf(
"catalog_settings_payload",
"continue_watching_preferences_payload",
"episode_release_notifications_payload",
"episode_release_notification_scheduled_ids",
"selected_theme",
"amoled_enabled",
"show_loading_overlay",

View file

@ -0,0 +1,132 @@
package com.nuvio.app.features.notifications
import com.nuvio.app.core.storage.ProfileScopedKey
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSCalendar
import platform.Foundation.NSDate
import platform.Foundation.NSDateComponents
import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
import platform.UserNotifications.UNAuthorizationOptionAlert
import platform.UserNotifications.UNAuthorizationOptionBadge
import platform.UserNotifications.UNAuthorizationOptionSound
import platform.UserNotifications.UNAuthorizationStatusAuthorized
import platform.UserNotifications.UNAuthorizationStatusProvisional
import platform.UserNotifications.UNCalendarNotificationTrigger
import platform.UserNotifications.UNMutableNotificationContent
import platform.UserNotifications.UNNotificationRequest
import platform.UserNotifications.UNTimeIntervalNotificationTrigger
import platform.UserNotifications.UNUserNotificationCenter
import kotlin.coroutines.resume
@OptIn(ExperimentalForeignApi::class)
internal actual object EpisodeReleaseNotificationPlatform {
private const val scheduledIdsKey = "episode_release_notification_scheduled_ids"
actual suspend fun notificationsAuthorized(): Boolean = suspendCancellableCoroutine { continuation ->
UNUserNotificationCenter.currentNotificationCenter().getNotificationSettingsWithCompletionHandler { settings ->
val status = settings?.authorizationStatus
continuation.resume(
status == UNAuthorizationStatusAuthorized || status == UNAuthorizationStatusProvisional,
)
}
}
actual suspend fun requestAuthorization(): Boolean = suspendCancellableCoroutine { continuation ->
UNUserNotificationCenter.currentNotificationCenter().requestAuthorizationWithOptions(
options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge,
) { granted, _ ->
continuation.resume(granted)
}
}
actual suspend fun scheduleEpisodeReleaseNotifications(requests: List<EpisodeReleaseNotificationRequest>) {
clearScheduledEpisodeReleaseNotifications()
val center = UNUserNotificationCenter.currentNotificationCenter()
val scheduledIds = mutableListOf<String>()
requests.forEach { request ->
val dateComponents = buildDateComponents(request.releaseDateIso) ?: return@forEach
val scheduledDate = NSCalendar.currentCalendar.dateFromComponents(dateComponents) ?: return@forEach
if (scheduledDate.timeIntervalSince1970 <= NSDate().timeIntervalSince1970) return@forEach
val content = UNMutableNotificationContent().apply {
setTitle(request.notificationTitle)
setBody(request.notificationBody)
setUserInfo(mapOf("deeplink" to request.deepLinkUrl))
}
val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents(
dateComponents = dateComponents,
repeats = false,
)
val notificationRequest = UNNotificationRequest.requestWithIdentifier(
identifier = request.requestId,
content = content,
trigger = trigger,
)
center.addNotificationRequest(notificationRequest) { _ -> }
scheduledIds += request.requestId
}
NSUserDefaults.standardUserDefaults.setObject(
scheduledIds.joinToString(separator = "|"),
forKey = ProfileScopedKey.of(scheduledIdsKey),
)
}
actual suspend fun clearScheduledEpisodeReleaseNotifications() {
val identifiers = trackedScheduledIds()
if (identifiers.isNotEmpty()) {
UNUserNotificationCenter.currentNotificationCenter()
.removePendingNotificationRequestsWithIdentifiers(identifiers)
}
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(scheduledIdsKey))
}
actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) {
val content = UNMutableNotificationContent().apply {
setTitle(request.notificationTitle)
setBody(request.notificationBody)
setUserInfo(mapOf("deeplink" to request.deepLinkUrl))
}
val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(
timeInterval = 1.0,
repeats = false,
)
val notificationRequest = UNNotificationRequest.requestWithIdentifier(
identifier = request.requestId,
content = content,
trigger = trigger,
)
UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(notificationRequest) { _ -> }
}
private fun trackedScheduledIds(): List<String> =
NSUserDefaults.standardUserDefaults
.stringForKey(ProfileScopedKey.of(scheduledIdsKey))
?.split('|')
?.filter { value -> value.isNotBlank() }
.orEmpty()
private fun buildDateComponents(releaseDateIso: String): NSDateComponents? {
val parts = releaseDateIso.split('-')
if (parts.size != 3) return null
val year = parts[0].toLongOrNull() ?: return null
val month = parts[1].toLongOrNull() ?: return null
val day = parts[2].toLongOrNull() ?: return null
return NSDateComponents().apply {
this.year = year
this.month = month
this.day = day
this.hour = EpisodeReleaseNotificationHour.toLong()
this.minute = EpisodeReleaseNotificationMinute.toLong()
this.second = 0
this.calendar = NSCalendar.currentCalendar
setTimeZone(NSCalendar.currentCalendar.timeZone)
}
}
}

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.notifications
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.dateWithTimeIntervalSince1970
internal actual object EpisodeReleaseNotificationsClock {
actual fun isoDateFromEpochMs(epochMs: Long): String {
val formatter = NSDateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.stringFromDate(
NSDate.dateWithTimeIntervalSince1970(epochMs.toDouble() / 1000.0),
)
}
}

View file

@ -0,0 +1,18 @@
package com.nuvio.app.features.notifications
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
internal actual object EpisodeReleaseNotificationsStorage {
private const val payloadKey = "episode_release_notifications_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(
payload,
forKey = ProfileScopedKey.of(payloadKey),
)
}
}

View file

@ -1,8 +1,14 @@
package com.nuvio.app.features.trakt
import platform.Foundation.NSDate
import platform.Foundation.NSISO8601DateFormatter
import platform.Foundation.timeIntervalSince1970
internal actual object TraktPlatformClock {
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
NSISO8601DateFormatter()
.dateFromString(value)
?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
}

View file

@ -9,6 +9,7 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.17.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.9.6"
androidx-work = "2.10.3"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.0"
coil = "3.4.0"
@ -37,6 +38,7 @@ androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navi
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }

View file

@ -1,14 +1,17 @@
import UIKit
import UserNotifications
import ComposeApp
private let lockPlayerToLandscapeNotification = Notification.Name("NuvioPlayerLockLandscape")
private let unlockPlayerOrientationNotification = Notification.Name("NuvioPlayerUnlockOrientation")
final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate {
final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
OrientationLockCoordinator.shared.start()
UNUserNotificationCenter.current().delegate = self
return true
}
@ -18,6 +21,25 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate {
) -> UIInterfaceOrientationMask {
OrientationLockCoordinator.shared.supportedOrientations
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if let deepLink = response.notification.request.content.userInfo["deeplink"] as? String {
AppUrlBridgeKt.handleAppUrl(url: deepLink)
}
completionHandler()
}
}
final class OrientationLockCoordinator {

View file

@ -10,7 +10,7 @@ struct iOSApp: App {
ContentView()
.preferredColorScheme(.dark)
.onOpenURL { url in
TraktAuthBridgeKt.handleTraktAuthCallbackUrl(url: url.absoluteString)
AppUrlBridgeKt.handleAppUrl(url: url.absoluteString)
}
}
}