diff --git a/app/schemas/io.heckel.ntfy.data.Database/6.json b/app/schemas/io.heckel.ntfy.data.Database/6.json index 8e481f8..c6efc44 100644 --- a/app/schemas/io.heckel.ntfy.data.Database/6.json +++ b/app/schemas/io.heckel.ntfy.data.Database/6.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 6, - "identityHash": "6fd36c6995d3ae734f4ba7c8beaf9a95", + "identityHash": "1ab02dd84a7f2655b4fc651574b24240", "entities": [ { "tableName": "Subscription", @@ -80,7 +80,7 @@ }, { "tableName": "Notification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `attachmentName` TEXT, `attachmentType` TEXT, `attachmentSize` INTEGER, `attachmentExpires` INTEGER, `attachmentPreviewUrl` TEXT, `attachmentUrl` TEXT, `attachmentContentUri` TEXT, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_previewUrl` TEXT, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_previewFile` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", "fields": [ { "fieldPath": "id", @@ -137,53 +137,65 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "attachmentName", - "columnName": "attachmentName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachmentType", - "columnName": "attachmentType", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachmentSize", - "columnName": "attachmentSize", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "attachmentExpires", - "columnName": "attachmentExpires", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "attachmentPreviewUrl", - "columnName": "attachmentPreviewUrl", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachmentUrl", - "columnName": "attachmentUrl", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachmentContentUri", - "columnName": "attachmentContentUri", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "deleted", "columnName": "deleted", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "attachment.name", + "columnName": "attachment_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.type", + "columnName": "attachment_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.size", + "columnName": "attachment_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachment.expires", + "columnName": "attachment_expires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachment.previewUrl", + "columnName": "attachment_previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.url", + "columnName": "attachment_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.contentUri", + "columnName": "attachment_contentUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.previewFile", + "columnName": "attachment_previewFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.progress", + "columnName": "attachment_progress", + "affinity": "INTEGER", + "notNull": false } ], "primaryKey": { @@ -200,7 +212,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fd36c6995d3ae734f4ba7c8beaf9a95')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ab02dd84a7f2655b4fc651574b24240')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 332f012..033411e 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -15,6 +15,7 @@ data class Subscription( @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token + // TODO autoDownloadAttachments, minPriority @Ignore val totalCount: Int = 0, // Total notifications @Ignore val newCount: Int = 0, // New notifications @Ignore val lastActive: Long = 0, // Unix timestamp @@ -52,16 +53,26 @@ data class Notification( @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max @ColumnInfo(name = "tags") val tags: String, @ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click - @ColumnInfo(name = "attachmentName") val attachmentName: String?, // Filename - @ColumnInfo(name = "attachmentType") val attachmentType: String?, // MIME type - @ColumnInfo(name = "attachmentSize") val attachmentSize: Long?, // Size in bytes - @ColumnInfo(name = "attachmentExpires") val attachmentExpires: Long?, // Unix timestamp - @ColumnInfo(name = "attachmentPreviewUrl") val attachmentPreviewUrl: String?, - @ColumnInfo(name = "attachmentUrl") val attachmentUrl: String?, - @ColumnInfo(name = "attachmentContentUri") val attachmentContentUri: String?, + @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, ) +@Entity +data class Attachment( + @ColumnInfo(name = "name") val name: String?, // Filename + @ColumnInfo(name = "type") val type: String?, // MIME type + @ColumnInfo(name = "size") val size: Long?, // Size in bytes + @ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp + @ColumnInfo(name = "previewUrl") val previewUrl: String?, + @ColumnInfo(name = "url") val url: String, + @ColumnInfo(name = "contentUri") val contentUri: String?, + @ColumnInfo(name = "previewFile") val previewFile: String?, + @ColumnInfo(name = "progress") val progress: Int, +) { + constructor(name: String?, type: String?, size: Long?, expires: Long?, previewUrl: String?, url: String) : + this(name, type, size, expires, previewUrl, url, null, null, 0) +} + @androidx.room.Database(entities = [Subscription::class, Notification::class], version = 6) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 2ffaa10..a108222 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.annotation.Keep import com.google.gson.Gson import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Notification import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrlJson @@ -112,6 +113,16 @@ class ApiService { val message = gson.fromJson(line, Message::class.java) if (message.event == EVENT_MESSAGE) { val topic = message.topic + val attachment = if (message.attachment?.url != null) { + Attachment( + name = message.attachment.name, + type = message.attachment.type, + size = message.attachment.size, + expires = message.attachment.expires, + previewUrl = message.attachment.preview_url, + url = message.attachment.url, + ) + } else null val notification = Notification( id = message.id, subscriptionId = 0, // TO BE SET downstream @@ -121,13 +132,7 @@ class ApiService { priority = toPriority(message.priority), tags = joinTags(message.tags), click = message.click ?: "", - attachmentName = message.attachment?.name, - attachmentType = message.attachment?.type, - attachmentSize = message.attachment?.size, - attachmentExpires = message.attachment?.expires, - attachmentPreviewUrl = message.attachment?.preview_url, - attachmentUrl = message.attachment?.url, - attachmentContentUri = null, + attachment = attachment, notificationId = Random.nextInt(), deleted = false ) @@ -149,6 +154,16 @@ class ApiService { private fun fromString(subscriptionId: Long, s: String): Notification { val message = gson.fromJson(s, Message::class.java) + val attachment = if (message.attachment?.url != null) { + Attachment( + name = message.attachment.name, + type = message.attachment.type, + size = message.attachment.size, + expires = message.attachment.expires, + previewUrl = message.attachment.preview_url, + url = message.attachment.url, + ) + } else null return Notification( id = message.id, subscriptionId = subscriptionId, @@ -158,14 +173,8 @@ class ApiService { priority = toPriority(message.priority), tags = joinTags(message.tags), click = message.click ?: "", - attachmentName = message.attachment?.name, - attachmentType = message.attachment?.type, - attachmentSize = message.attachment?.size, - attachmentExpires = message.attachment?.expires, - attachmentPreviewUrl = message.attachment?.preview_url, - attachmentUrl = message.attachment?.url, - attachmentContentUri = null, - notificationId = 0, + attachment = attachment, + notificationId = 0, // zero! deleted = false ) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt index a961869..86c6fa6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt @@ -9,6 +9,7 @@ import android.util.Log import androidx.work.Worker import androidx.work.WorkerParameters import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription @@ -34,15 +35,16 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam val repository = app.repository val notification = repository.getNotification(notificationId) ?: return Result.failure() val subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() - if (notification.attachmentPreviewUrl != null) { - downloadPreview(repository, subscription, notification) + val attachment = notification.attachment ?: return Result.failure() + if (attachment.previewUrl != null) { + downloadPreview(subscription, notification, attachment) } - downloadAttachment(repository, subscription, notification) + downloadAttachment(repository, subscription, notification, attachment) return Result.success() } - private fun downloadPreview(repository: Repository, subscription: Subscription, notification: Notification) { - val url = notification.attachmentPreviewUrl ?: return + private fun downloadPreview(subscription: Subscription, notification: Notification, attachment: Attachment) { + val url = attachment.previewUrl ?: return Log.d(TAG, "Downloading preview from $url") val request = Request.Builder() @@ -63,21 +65,20 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam } } - private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification) { - val url = notification.attachmentUrl ?: return - Log.d(TAG, "Downloading attachment from $url") + private fun downloadAttachment(repository: Repository, subscription: Subscription, notification: Notification, attachment: Attachment) { + Log.d(TAG, "Downloading attachment from ${attachment.url}") val request = Request.Builder() - .url(url) + .url(attachment.url) .addHeader("User-Agent", ApiService.USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful || response.body == null) { throw Exception("Attachment download failed: ${response.code}") } - val name = notification.attachmentName ?: "attachment.bin" - val mimeType = notification.attachmentType ?: "application/octet-stream" - val size = notification.attachmentSize ?: 0 + val name = attachment.name ?: "attachment.bin" + val mimeType = attachment.type ?: "application/octet-stream" + val size = attachment.size ?: 0 val resolver = applicationContext.contentResolver val details = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) @@ -107,7 +108,8 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam } } Log.d(TAG, "Attachment download: successful response, proceeding with download") - val newNotification = notification.copy(attachmentContentUri = uri.toString()) + val newAttachment = attachment.copy(contentUri = uri.toString()) + val newNotification = notification.copy(attachment = newAttachment) repository.updateNotification(newNotification) notifier.update(subscription, newNotification) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index 9c754d1..7a8743d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -1,6 +1,10 @@ package io.heckel.ntfy.msg import android.content.Context +import android.util.Log +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.workDataOf import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Subscription @@ -25,6 +29,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { val notify = shouldNotify(subscription, notification, muted) val broadcast = shouldBroadcast(subscription) val distribute = shouldDistribute(subscription) + val download = shouldDownload(subscription, notification) if (notify) { notifier.display(subscription, notification) } @@ -36,6 +41,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { distributor.sendMessage(appId, connectorToken, notification.message) } } + if (download) { + // Download attachment (+ preview if available) in the background via WorkManager + // The indirection via WorkManager is required since this code may be executed + // in a doze state and Internet may not be available. It's also best practice apparently. + scheduleAttachmentDownload(notification) + } + } + + private fun shouldDownload(subscription: Subscription, notification: Notification): Boolean { + return notification.attachment != null } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { @@ -67,4 +82,19 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000) } + + private fun scheduleAttachmentDownload(notification: Notification) { + Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)") + val workManager = WorkManager.getInstance(context) + val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) + .setInputData(workDataOf( + "id" to notification.id, + )) + .build() + workManager.enqueue(workRequest) + } + + companion object { + private const val TAG = "NtfyNotifDispatch" + } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index 8c2c03e..1f69582 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -32,16 +32,7 @@ class NotificationService(val context: Context) { fun display(subscription: Subscription, notification: Notification) { Log.d(TAG, "Displaying notification $notification") - - // Display notification immediately displayInternal(subscription, notification) - - // Download attachment (+ preview if available) in the background via WorkManager - // The indirection via WorkManager is required since this code may be executed - // in a doze state and Internet may not be available. It's also best practice apparently. - if (notification.attachmentUrl != null) { - scheduleAttachmentDownload(subscription, notification) - } } fun update(subscription: Subscription, notification: Notification, progress: Int = PROGRESS_NONE) { @@ -133,32 +124,21 @@ class NotificationService(val context: Context) { } private fun maybeAddOpenAction(notificationBuilder: NotificationCompat.Builder, notification: Notification) { - if (notification.attachmentContentUri != null) { - val contentUri = Uri.parse(notification.attachmentContentUri) + if (notification.attachment?.contentUri != null) { + val contentUri = Uri.parse(notification.attachment.contentUri) val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, contentUri), 0) notificationBuilder.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build()) } } private fun maybeAddCopyUrlAction(builder: NotificationCompat.Builder, notification: Notification) { - if (notification.attachmentUrl != null) { + if (notification.attachment?.url != null) { // XXXXXXXXx - val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0) + val copyUrlIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachment.url)), 0) builder.addAction(NotificationCompat.Action.Builder(0, "Copy URL", copyUrlIntent).build()) } } - private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) { - Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)") - val workManager = WorkManager.getInstance(context) - val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) - .setInputData(workDataOf( - "id" to notification.id, - )) - .build() - workManager.enqueue(workRequest) - } - private fun detailActivityIntent(subscription: Subscription): PendingIntent? { val intent = Intent(context, DetailActivity::class.java) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) @@ -223,7 +203,7 @@ class NotificationService(val context: Context) { const val PROGRESS_NONE = -1 const val PROGRESS_INDETERMINATE = -2 - private const val TAG = "NtfyNotificationService" + private const val TAG = "NtfyNotifService" private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_DEFAULT = "ntfy" diff --git a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt index 9c30464..2bd5142 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -6,6 +6,7 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.heckel.ntfy.R import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Notification import io.heckel.ntfy.msg.* import io.heckel.ntfy.service.SubscriberService @@ -81,6 +82,16 @@ class FirebaseService : FirebaseMessagingService() { } // Add notification + val attachment = if (attachmentUrl != null) { + Attachment( + name = attachmentName, + type = attachmentType, + size = attachmentSize, + expires = attachmentExpires, + previewUrl = attachmentPreviewUrl, + url = attachmentUrl, + ) + } else null val notification = Notification( id = id, subscriptionId = subscription.id, @@ -90,13 +101,7 @@ class FirebaseService : FirebaseMessagingService() { priority = toPriority(priority), tags = tags ?: "", click = click ?: "", - attachmentName = attachmentName, - attachmentType = attachmentType, - attachmentSize = attachmentSize, - attachmentExpires = attachmentExpires, - attachmentPreviewUrl = attachmentPreviewUrl, - attachmentUrl = attachmentUrl, - attachmentContentUri = null, + attachment = attachment, notificationId = Random.nextInt(), deleted = false )