added backdrop for notifications

This commit is contained in:
tapframe 2026-04-04 12:59:43 +05:30
parent 3f1d13191a
commit c2f2e07161
6 changed files with 210 additions and 48 deletions

View file

@ -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<Boolean>? = 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<EpisodeReleaseNotificationWorker>()
@ -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) {

View file

@ -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)

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M7.2,1.9
C5.15,1.9 3.5,3.62 3.5,5.73
V18.27
C3.5,20.38 5.15,22.1 7.2,22.1
C7.88,22.1 8.56,21.91 9.16,21.56
L18.03,16.34
C19.49,15.48 20.4,13.88 20.4,12
C20.4,10.12 19.49,8.52 18.03,7.66
L9.16,2.44
C8.56,2.09 7.88,1.9 7.2,1.9
Z
M9.08,7.54
C8.81,7.38 8.49,7.38 8.22,7.53
C7.95,7.69 7.79,7.98 7.79,8.29
V15.71
C7.79,16.02 7.95,16.31 8.22,16.47
C8.49,16.62 8.81,16.62 9.08,16.46
L14.99,12.75
C15.24,12.59 15.39,12.31 15.39,12
C15.39,11.69 15.24,11.41 14.99,11.25
L9.08,7.54
Z" />
</vector>

View file

@ -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

View file

@ -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,
)
}
}

View file

@ -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<Any?, *>?,
null,
)
}
private suspend fun downloadBackdropToTemporaryFile(
requestId: String,
imageUrl: String,
): NSURL? {
val bytes: ByteArray = runCatching {
httpClient.get(imageUrl).body<ByteArray>()
}.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