mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
feat: notifications init
This commit is contained in:
parent
7cc393e6a9
commit
3f1d13191a
32 changed files with 1409 additions and 10 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.notifications
|
||||
|
||||
internal expect object EpisodeReleaseNotificationsClock {
|
||||
fun isoDateFromEpochMs(epochMs: Long): String
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.notifications
|
||||
|
||||
internal expect object EpisodeReleaseNotificationsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ package com.nuvio.app.features.trakt
|
|||
|
||||
internal expect object TraktPlatformClock {
|
||||
fun nowEpochMs(): Long
|
||||
fun parseIsoDateTimeToEpochMs(value: String): Long?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ struct iOSApp: App {
|
|||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
.onOpenURL { url in
|
||||
TraktAuthBridgeKt.handleTraktAuthCallbackUrl(url: url.absoluteString)
|
||||
AppUrlBridgeKt.handleAppUrl(url: url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue