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 index 5b45bcdbb..cee05234d 100644 --- 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 @@ -1,10 +1,14 @@ package com.nuvio.app.features.notifications import android.Manifest +import android.app.PendingIntent import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build import androidx.activity.ComponentActivity import androidx.core.app.ActivityCompat @@ -16,6 +20,11 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.WorkManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get import java.time.LocalDate import java.time.ZoneId import java.util.concurrent.TimeUnit @@ -34,10 +43,20 @@ internal actual object EpisodeReleaseNotificationPlatform { internal const val workerTitleKey = "title" internal const val workerBodyKey = "body" internal const val workerDeepLinkKey = "deep_link" + internal const val workerBackdropUrlKey = "backdrop_url" private var appContext: Context? = null private var currentActivity: ComponentActivity? = null private var pendingPermissionContinuation: kotlin.coroutines.Continuation? = null + private val httpClient by lazy { + HttpClient(Android) { + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 15_000 + socketTimeoutMillis = 15_000 + } + } + } fun initialize(context: Context) { appContext = context.applicationContext @@ -127,6 +146,7 @@ internal actual object EpisodeReleaseNotificationPlatform { .putString(workerTitleKey, request.notificationTitle) .putString(workerBodyKey, request.notificationBody) .putString(workerDeepLinkKey, request.deepLinkUrl) + .putString(workerBackdropUrlKey, request.backdropUrl) .build() val workRequest = OneTimeWorkRequestBuilder() @@ -169,32 +189,64 @@ internal actual object EpisodeReleaseNotificationPlatform { 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 = buildNotification(context, request) - val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(com.nuvio.app.R.mipmap.ic_launcher) + NotificationManagerCompat.from(context) + .notify(kotlin.math.abs(request.requestId.hashCode()), notification) + } + + internal suspend fun buildNotification( + context: Context, + request: EpisodeReleaseNotificationRequest, + ): android.app.Notification { + val pendingIntent = buildPendingIntent(context, request) + val backdropBitmap = loadBackdropBitmap(request.backdropUrl) + val appIconBitmap = BitmapFactory.decodeResource(context.resources, com.nuvio.app.R.mipmap.ic_launcher) + + return NotificationCompat.Builder(context, channelId) + .setSmallIcon(com.nuvio.app.R.drawable.ic_notification_small) .setContentTitle(request.notificationTitle) .setContentText(request.notificationBody) - .setStyle(NotificationCompat.BigTextStyle().bigText(request.notificationBody)) + .setStyle( + backdropBitmap?.let { bitmap -> + NotificationCompat.BigPictureStyle() + .bigPicture(bitmap) + .bigLargeIcon(appIconBitmap) + .setSummaryText(request.notificationBody) + } ?: NotificationCompat.BigTextStyle().bigText(request.notificationBody), + ) + .setLargeIcon(appIconBitmap) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(pendingIntent) .build() + } - NotificationManagerCompat.from(context) - .notify(kotlin.math.abs(request.requestId.hashCode()), notification) + internal suspend fun loadBackdropBitmap(backdropUrl: String?): Bitmap? { + val imageUrl = backdropUrl?.trim().takeUnless { it.isNullOrEmpty() } ?: return null + return runCatching { + val bytes: ByteArray = httpClient.get(imageUrl).body() + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } + + private fun buildPendingIntent( + context: Context, + request: EpisodeReleaseNotificationRequest, + ): PendingIntent { + val launchIntent = Intent(context, com.nuvio.app.MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = android.net.Uri.parse(request.deepLinkUrl) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return PendingIntent.getActivity( + context, + kotlin.math.abs(request.requestId.hashCode()), + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun cancelTrackedWork(workManager: WorkManager) { 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 index a0f6f75c9..c9557fd2b 100644 --- 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 @@ -7,7 +7,6 @@ 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( @@ -28,28 +27,21 @@ class EpisodeReleaseNotificationWorker( ?: return Result.failure() val deepLink = inputData.getString(EpisodeReleaseNotificationPlatform.workerDeepLinkKey) ?: return Result.failure() + val backdropUrl = inputData.getString(EpisodeReleaseNotificationPlatform.workerBackdropUrlKey) - 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 request = EpisodeReleaseNotificationRequest( + requestId = requestId, + notificationTitle = title, + notificationBody = body, + releaseDateIso = "", + deepLinkUrl = deepLink, + backdropUrl = backdropUrl, ) - 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() + val notification = EpisodeReleaseNotificationPlatform.buildNotification( + context = applicationContext, + request = request, + ) NotificationManagerCompat.from(applicationContext) .notify(abs(requestId.hashCode()), notification) diff --git a/composeApp/src/androidMain/res/drawable/ic_notification_small.xml b/composeApp/src/androidMain/res/drawable/ic_notification_small.xml new file mode 100644 index 000000000..87868d5a0 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_notification_small.xml @@ -0,0 +1,32 @@ + + + + \ 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 index 9bc191207..fa4ec03c4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -33,6 +33,7 @@ internal data class EpisodeReleaseNotificationRequest( val notificationBody: String, val releaseDateIso: String, val deepLinkUrl: String, + val backdropUrl: String? = null, ) internal const val EpisodeReleaseNotificationHour = 9 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 index 970cd2870..b5fd682a0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -187,6 +187,7 @@ object EpisodeReleaseNotificationsRepository { notificationBody = "Test notification from Nuvio. Tap to open ${target.name}.", releaseDateIso = CurrentDateProvider.todayIsoDate(), deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id), + backdropUrl = target.banner ?: target.poster, ) runCatching { @@ -442,6 +443,7 @@ object EpisodeReleaseNotificationsRepository { type = trackedShow.contentType, id = trackedShow.contentId, ), + backdropUrl = meta.background ?: episode.thumbnail ?: episode.seasonPoster ?: meta.poster, ) } } 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 index b7cce4fe3..a5076d2e8 100644 --- 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 @@ -2,12 +2,24 @@ package com.nuvio.app.features.notifications import com.nuvio.app.core.storage.ProfileScopedKey import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get import kotlinx.coroutines.suspendCancellableCoroutine import platform.Foundation.NSCalendar import platform.Foundation.NSDate import platform.Foundation.NSDateComponents +import platform.Foundation.NSFileManager import platform.Foundation.NSUserDefaults +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL import platform.Foundation.timeIntervalSince1970 +import platform.UserNotifications.UNNotificationAttachment import platform.UserNotifications.UNAuthorizationOptionAlert import platform.UserNotifications.UNAuthorizationOptionBadge import platform.UserNotifications.UNAuthorizationOptionSound @@ -18,11 +30,22 @@ import platform.UserNotifications.UNMutableNotificationContent import platform.UserNotifications.UNNotificationRequest import platform.UserNotifications.UNTimeIntervalNotificationTrigger import platform.UserNotifications.UNUserNotificationCenter +import platform.posix.fclose +import platform.posix.fopen +import platform.posix.fwrite import kotlin.coroutines.resume @OptIn(ExperimentalForeignApi::class) internal actual object EpisodeReleaseNotificationPlatform { private const val scheduledIdsKey = "episode_release_notification_scheduled_ids" + private const val attachmentDirectoryName = "episode_release_notification_attachments" + private val httpClient = HttpClient(Darwin) { + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 15_000 + socketTimeoutMillis = 15_000 + } + } actual suspend fun notificationsAuthorized(): Boolean = suspendCancellableCoroutine { continuation -> UNUserNotificationCenter.currentNotificationCenter().getNotificationSettingsWithCompletionHandler { settings -> @@ -52,11 +75,7 @@ internal actual object EpisodeReleaseNotificationPlatform { 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 content = buildNotificationContent(request) val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( dateComponents = dateComponents, repeats = false, @@ -86,11 +105,7 @@ internal actual object EpisodeReleaseNotificationPlatform { } actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) { - val content = UNMutableNotificationContent().apply { - setTitle(request.notificationTitle) - setBody(request.notificationBody) - setUserInfo(mapOf("deeplink" to request.deepLinkUrl)) - } + val content = buildNotificationContent(request) val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval( timeInterval = 1.0, repeats = false, @@ -110,6 +125,74 @@ internal actual object EpisodeReleaseNotificationPlatform { ?.filter { value -> value.isNotBlank() } .orEmpty() + private suspend fun buildNotificationContent(request: EpisodeReleaseNotificationRequest): UNMutableNotificationContent = + UNMutableNotificationContent().apply { + setTitle(request.notificationTitle) + setBody(request.notificationBody) + setUserInfo(mapOf("deeplink" to request.deepLinkUrl)) + attachmentFor(request)?.let { attachment -> + setAttachments(listOf(attachment)) + } + } + + private suspend fun attachmentFor(request: EpisodeReleaseNotificationRequest): UNNotificationAttachment? { + val imageUrl = request.backdropUrl?.trim().takeUnless { it.isNullOrEmpty() } ?: return null + val localUrl = downloadBackdropToTemporaryFile( + requestId = request.requestId, + imageUrl = imageUrl, + ) ?: return null + + return UNNotificationAttachment.attachmentWithIdentifier( + request.requestId, + localUrl, + null as Map?, + null, + ) + } + + private suspend fun downloadBackdropToTemporaryFile( + requestId: String, + imageUrl: String, + ): NSURL? { + val bytes: ByteArray = runCatching { + httpClient.get(imageUrl).body() + }.getOrNull() ?: return null + + val directoryPath = NSTemporaryDirectory().trimEnd('/') + "/" + attachmentDirectoryName + NSFileManager.defaultManager.createDirectoryAtPath( + path = directoryPath, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + + val fileExtension = imageUrl.substringAfterLast('.', "jpg") + .substringBefore('?') + .takeIf { extension -> extension.length in 2..5 } + ?: "jpg" + val filePath = "$directoryPath/$requestId.$fileExtension" + val fileUrl = NSURL.fileURLWithPath(filePath) + val wrote = bytes.writeToFile(filePath) + if (!wrote) return null + return fileUrl + } + + private fun ByteArray.writeToFile(path: String): Boolean = + usePinned { pinned -> + val file = fopen(path, "wb") ?: return false + try { + val written = fwrite( + pinned.addressOf(0), + 1.convert(), + size.convert(), + file, + ) + written.toLong() == size.toLong() + } finally { + fclose(file) + } + } + private fun buildDateComponents(releaseDateIso: String): NSDateComponents? { val parts = releaseDateIso.split('-') if (parts.size != 3) return null