mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-17 12:57:03 +00:00
added backdrop for notifications
This commit is contained in:
parent
3f1d13191a
commit
c2f2e07161
6 changed files with 210 additions and 48 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue