From 3f1d13191a36660576bdfbff19db499dd4162646 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:29:25 +0530 Subject: [PATCH] feat: notifications init --- composeApp/build.gradle.kts | 1 + .../src/androidMain/AndroidManifest.xml | 1 + .../kotlin/com/nuvio/app/MainActivity.kt | 35 +- ...PlatformLocalAccountDataCleaner.android.kt | 2 + ...sodeReleaseNotificationPlatform.android.kt | 245 ++++++++++ ...pisodeReleaseNotificationWorker.android.kt | 59 +++ ...pisodeReleaseNotificationsClock.android.kt | 12 + ...sodeReleaseNotificationsStorage.android.kt | 26 + .../trakt/TraktPlatformClock.android.kt | 6 + .../commonMain/kotlin/com/nuvio/app/App.kt | 24 + .../nuvio/app/core/deeplink/AppUrlBridge.kt | 65 +++ .../core/storage/LocalAccountDataCleaner.kt | 2 + .../EpisodeReleaseNotificationPlatform.kt | 9 + .../EpisodeReleaseNotificationsClock.kt | 5 + .../EpisodeReleaseNotificationsModels.kt | 90 ++++ .../EpisodeReleaseNotificationsRepository.kt | 448 ++++++++++++++++++ .../EpisodeReleaseNotificationsStorage.kt | 6 + .../features/profiles/ProfileRepository.kt | 2 + .../settings/NotificationsSettingsPage.kt | 139 ++++++ .../app/features/settings/SettingsModels.kt | 6 + .../app/features/settings/SettingsRootPage.kt | 10 + .../app/features/settings/SettingsScreen.kt | 20 + .../features/trakt/TraktLibraryRepository.kt | 4 +- .../app/features/trakt/TraktPlatformClock.kt | 1 + .../PlatformLocalAccountDataCleaner.ios.kt | 2 + .../EpisodeReleaseNotificationPlatform.ios.kt | 132 ++++++ .../EpisodeReleaseNotificationsClock.ios.kt | 15 + .../EpisodeReleaseNotificationsStorage.ios.kt | 18 + .../features/trakt/TraktPlatformClock.ios.kt | 6 + gradle/libs.versions.toml | 2 + .../iosApp/OrientationLockCoordinator.swift | 24 +- iosApp/iosApp/iOSApp.swift | 2 +- 32 files changed, 1409 insertions(+), 10 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationWorker.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 83353f11f..65f34d30b 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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") diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 837123207..dea0693e0 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,7 @@ + , + 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) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 0d6f9b709..8f9d2dd47 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -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", ) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt new file mode 100644 index 000000000..5b45bcdbb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt @@ -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? = 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) { + val context = appContext ?: return + ensureNotificationChannel() + + withContext(Dispatchers.IO) { + val workManager = WorkManager.getInstance(context) + cancelTrackedWork(workManager) + + val nowEpochMs = System.currentTimeMillis() + val scheduledIds = mutableListOf() + + 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() + .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" +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationWorker.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationWorker.android.kt new file mode 100644 index 000000000..a0f6f75c9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationWorker.android.kt @@ -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() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.android.kt new file mode 100644 index 000000000..d18137ac3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.android.kt @@ -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() +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.android.kt new file mode 100644 index 000000000..231bb88f2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.android.kt @@ -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() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt index fe4db2725..4eda3a912 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 63a3ed80a..a81000ff3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt new file mode 100644 index 000000000..6edb9e2b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -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(null) + val pendingDeepLink: StateFlow = _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 + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index b04c14497..fab12c6f6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.kt new file mode 100644 index 000000000..5fd22cf0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.kt @@ -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) + suspend fun clearScheduledEpisodeReleaseNotifications() + suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.kt new file mode 100644 index 000000000..8a47147b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.notifications + +internal expect object EpisodeReleaseNotificationsClock { + fun isoDateFromEpochMs(epochMs: Long): String +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt new file mode 100644 index 000000000..9bc191207 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -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 = 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" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt new file mode 100644 index 000000000..970cd2870 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -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 = _uiState.asStateFlow() + + private var hasLoaded = false + private var trackedShowsByKey: MutableMap = 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(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() + + 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 { + 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, + ), + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.kt new file mode 100644 index 000000000..fdb9051ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.notifications + +internal expect object EpisodeReleaseNotificationsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index bf86750a8..49c5f69fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt new file mode 100644 index 000000000..e8777177c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt @@ -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, + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index f9a48fe10..2e71a4baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index ff84f7593..93554e7b0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -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.", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 51bee077e..a17d932dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 03ebb4183..5d152c7e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt index 5649bc228..7f0ac4e20 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt @@ -2,4 +2,5 @@ package com.nuvio.app.features.trakt internal expect object TraktPlatformClock { fun nowEpochMs(): Long + fun parseIsoDateTimeToEpochMs(value: String): Long? } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 41743e84e..921ba2d82 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -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", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.ios.kt new file mode 100644 index 000000000..b7cce4fe3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.ios.kt @@ -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) { + clearScheduledEpisodeReleaseNotifications() + + val center = UNUserNotificationCenter.currentNotificationCenter() + val scheduledIds = mutableListOf() + + 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 = + 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) + } + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.ios.kt new file mode 100644 index 000000000..6751e435e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsClock.ios.kt @@ -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), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.ios.kt new file mode 100644 index 000000000..be9f7e7eb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsStorage.ios.kt @@ -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), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt index 8ac635584..77e6d5851 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt @@ -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() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6111a09e..523fc532c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index 8f72fe51b..cf9c6d8cf 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -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 { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 5c1cf9062..541551c07 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -10,7 +10,7 @@ struct iOSApp: App { ContentView() .preferredColorScheme(.dark) .onOpenURL { url in - TraktAuthBridgeKt.handleTraktAuthCallbackUrl(url: url.absoluteString) + AppUrlBridgeKt.handleAppUrl(url: url.absoluteString) } } }