From ecefdd3df6d75893e0f6b430b23154b5b55e6ed8 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 16 Jul 2022 14:32:09 -0600 Subject: [PATCH] notification icons --- .../io.heckel.ntfy.db.Database/11.json | 44 +++- .../io.heckel.ntfy.db.Database/12.json | 30 ++- .../java/io/heckel/ntfy/backup/Backuper.kt | 32 +++ .../main/java/io/heckel/ntfy/db/Database.kt | 19 ++ .../main/java/io/heckel/ntfy/db/Repository.kt | 4 + ...dWorker.kt => DownloadAttachmentWorker.kt} | 14 +- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 193 ++++++++++++++++++ .../io/heckel/ntfy/msg/DownloadManager.kt | 65 +++++- .../main/java/io/heckel/ntfy/msg/Message.kt | 8 + .../heckel/ntfy/msg/NotificationDispatcher.kt | 24 ++- .../io/heckel/ntfy/msg/NotificationParser.kt | 9 + .../io/heckel/ntfy/msg/NotificationService.kt | 10 +- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 8 +- .../heckel/ntfy/ui/DetailSettingsActivity.kt | 5 +- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 26 +++ app/src/main/res/values/strings.xml | 1 + 16 files changed, 457 insertions(+), 35 deletions(-) rename app/src/main/java/io/heckel/ntfy/msg/{DownloadWorker.kt => DownloadAttachmentWorker.kt} (93%) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt diff --git a/app/schemas/io.heckel.ntfy.db.Database/11.json b/app/schemas/io.heckel.ntfy.db.Database/11.json index b54336c..8d8e064 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/11.json +++ b/app/schemas/io.heckel.ntfy.db.Database/11.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "31f8e6a2032d1d404fad4307abf23e1b", + "identityHash": "5a061926458ed65c80431be0a69a2450", "entities": [ { "tableName": "Subscription", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -50,6 +50,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "icon", "columnName": "icon", @@ -67,6 +73,12 @@ "columnName": "upConnectorToken", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -100,7 +112,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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, 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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_type` TEXT, `icon_size` INTEGER, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", "fields": [ { "fieldPath": "id", @@ -175,6 +187,30 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.type", + "columnName": "icon_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "attachment.name", "columnName": "attachment_name", @@ -314,7 +350,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, '31f8e6a2032d1d404fad4307abf23e1b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')" ] } } \ No newline at end of file diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 8b1a443..31f125c 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/12.json +++ b/app/schemas/io.heckel.ntfy.db.Database/12.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 12, - "identityHash": "9363ad5196e88862acceb1bb9ee91124", + "identityHash": "5a061926458ed65c80431be0a69a2450", "entities": [ { "tableName": "Subscription", @@ -112,7 +112,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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, 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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_type` TEXT, `icon_size` INTEGER, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", "fields": [ { "fieldPath": "id", @@ -187,6 +187,30 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.type", + "columnName": "icon_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "attachment.name", "columnName": "attachment_name", @@ -326,7 +350,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, '9363ad5196e88862acceb1bb9ee91124')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a061926458ed65c80431be0a69a2450')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 9ce3069..49c6464 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -6,6 +6,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.stream.JsonReader import io.heckel.ntfy.app.Application +import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Repository import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.topicUrl @@ -148,6 +149,16 @@ class Backuper(val context: Context) { } else { null } + val icon = if (n.icon != null) { + io.heckel.ntfy.db.Icon( + url = n.icon.url, + type = n.icon.type, + size = n.icon.size, + contentUri = n.icon.contentUri, + ) + } else { + null + } repository.addNotification(io.heckel.ntfy.db.Notification( id = n.id, subscriptionId = n.subscriptionId, @@ -159,6 +170,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, + icon = icon, actions = actions, attachment = attachment, deleted = n.deleted @@ -266,6 +278,16 @@ class Backuper(val context: Context) { } else { null } + val icon = if (n.icon != null) { + Icon( + url = n.icon.url, + type = n.icon.type, + size = n.icon.size, + contentUri = n.icon.contentUri, + ) + } else { + null + } Notification( id = n.id, subscriptionId = n.subscriptionId, @@ -276,6 +298,7 @@ class Backuper(val context: Context) { priority = n.priority, tags = n.tags, click = n.click, + icon = icon, actions = actions, attachment = attachment, deleted = n.deleted @@ -347,6 +370,7 @@ data class Notification( val priority: Int, // 1=min, 3=default, 5=max val tags: String, val click: String, // URL/intent to open on notification click + val icon: Icon?, val actions: List?, val attachment: Attachment?, val deleted: Boolean @@ -377,6 +401,14 @@ data class Attachment( val progress: Int, // Progress during download, -1 if not downloaded ) +data class Icon( + val url: String, // URL (mandatory, see ntfy server) + val type: String?, // MIME type + val size: Long?, // Size in bytes + val contentUri: String?, // After it's downloaded, the content:// location + val progress: Int, // Progress during download, -1 if not downloaded +) + data class User( val baseUrl: String, val username: String, diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index f5ddb44..c56fa95 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -66,6 +66,7 @@ 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 + @Embedded(prefix = "icon_") val icon: Icon?, @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, @@ -91,6 +92,17 @@ const val ATTACHMENT_PROGRESS_FAILED = -3 const val ATTACHMENT_PROGRESS_DELETED = -4 const val ATTACHMENT_PROGRESS_DONE = 100 +@Entity +data class Icon( + @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) + @ColumnInfo(name = "type") val type: String?, // MIME type + @ColumnInfo(name = "size") val size: Long?, // Size in bytes + @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location +) { + constructor(url:String, type: String?, size: Long?) : + this(url, type, size, null) +} + @Entity data class Action( @ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager @@ -269,6 +281,10 @@ abstract class Database : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT") db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT") // Room limitation: Has to be nullable for @Embedded + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_type TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_size INT") + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT") } } } @@ -368,6 +384,9 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") fun listDeletedWithAttachments(): List + @Query("SELECT * FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") + fun listDeletedWithIcons(): List + @Insert(onConflict = OnConflictStrategy.IGNORE) fun add(notification: Notification) diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 9f13656..ed45597 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -92,6 +92,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listDeletedWithAttachments() } + fun getDeletedNotificationsWithIcons(): List { + return notificationDao.listDeletedWithIcons() + } + fun getNotificationsLiveData(subscriptionId: Long): LiveData> { return notificationDao.listFlow(subscriptionId).asLiveData() } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt similarity index 93% rename from app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt rename to app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index df97a81..bc22e9d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -21,7 +21,7 @@ import okhttp3.Response import java.io.File import java.util.concurrent.TimeUnit -class DownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { +class DownloadAttachmentWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { private val client = OkHttpClient.Builder() .callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request .connectTimeout(15, TimeUnit.SECONDS) @@ -80,9 +80,13 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W this.uri = uri // Required for cleanup in onStopped() Log.d(TAG, "Starting download to content URI: $uri") - val contentLength = response.headers["Content-Length"]?.toLongOrNull() var bytesCopied: Long = 0 val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") + val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { + repository.getAutoDownloadMaxSize() + } else { + null + } outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -102,8 +106,8 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W save(attachment.copy(progress = progress)) lastProgress = System.currentTimeMillis() } - if (contentLength != null && bytesCopied > contentLength) { - throw Exception("Attachment is longer than response headers said.") + if (downloadLimit != null && bytesCopied > downloadLimit) { + throw Exception("Attachment is longer than max download size.") } fileOut.write(buffer, 0, bytes) bytesCopied += bytes @@ -182,7 +186,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W Repository.AUTO_DOWNLOAD_NEVER -> return true Repository.AUTO_DOWNLOAD_ALWAYS -> return false else -> { - val size = attachment.size ?: return true // Abort if size unknown + val size = attachment.size ?: return false // Don't abort if size unknown return size > maxAutoDownloadSize } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt new file mode 100644 index 0000000..e4535a5 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -0,0 +1,193 @@ +package io.heckel.ntfy.msg + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.work.Worker +import androidx.work.WorkerParameters +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.R +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.db.* +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.ensureSafeNewFile +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.util.concurrent.TimeUnit + +class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { + private val client = OkHttpClient.Builder() + .callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + private val notifier = NotificationService(context) + private lateinit var repository: Repository + private lateinit var subscription: Subscription + private lateinit var notification: Notification + private lateinit var icon: Icon + private var uri: Uri? = null + + override fun doWork(): Result { + if (context.applicationContext !is Application) return Result.failure() + val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure() + val app = context.applicationContext as Application + repository = app.repository + notification = repository.getNotification(notificationId) ?: return Result.failure() + subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() + icon = notification.icon ?: return Result.failure() + try { + downloadIcon() + } catch (e: Exception) { + failed(e) + } + return Result.success() + } + + override fun onStopped() { + Log.d(TAG, "Icon download was canceled") + maybeDeleteFile() + } + + private fun downloadIcon() { + Log.d(TAG, "Downloading icon from ${icon.url}") + + try { + val request = Request.Builder() + .url(icon.url) + .addHeader("User-Agent", ApiService.USER_AGENT) + .build() + client.newCall(request).execute().use { response -> + Log.d(TAG, "Download: headers received: $response") + if (!response.isSuccessful || response.body == null) { + throw Exception("Unexpected response: ${response.code}") + } + save(updateIconFromResponse(response)) + if (shouldAbortDownload()) { + Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") + return + } + val resolver = applicationContext.contentResolver + val uri = createUri(notification) + this.uri = uri // Required for cleanup in onStopped() + + Log.d(TAG, "Starting download to content URI: $uri") + val contentLength = response.headers["Content-Length"]?.toLongOrNull() + var bytesCopied: Long = 0 + val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") + val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { + repository.getAutoDownloadMaxSize() + } else { + null + } + outFile.use { fileOut -> + val fileIn = response.body!!.byteStream() + val buffer = ByteArray(BUFFER_SIZE) + var bytes = fileIn.read(buffer) + while (bytes >= 0) { + if (downloadLimit != null && bytesCopied > downloadLimit) { + throw Exception("Icon is longer than max download size.") + } + fileOut.write(buffer, 0, bytes) + bytesCopied += bytes + bytes = fileIn.read(buffer) + } + } + Log.d(TAG, "Icon download: successful response, proceeding with download") + save(icon.copy( + size = bytesCopied, + contentUri = uri.toString() + )) + } + } catch (e: Exception) { + failed(e) + + // Toast in a Worker: https://stackoverflow.com/a/56428145/1440785 + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ + Toast + .makeText(context, context.getString(R.string.detail_item_icon_download_failed, e.message), Toast.LENGTH_LONG) + .show() + }, 200) + } + } + + private fun updateIconFromResponse(response: Response): Icon { + val size = if (response.headers["Content-Length"]?.toLongOrNull() != null) { + Log.d(TAG, "We got the long! icon here") + response.headers["Content-Length"]?.toLong() + } else { + icon.size // May be null! + } + val mimeType = if (response.headers["Content-Type"] != null) { + response.headers["Content-Type"] + } else { + val ext = MimeTypeMap.getFileExtensionFromUrl(icon.url) + if (ext != null) { + val typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) + typeFromExt ?: icon.type // May be null! + } else { + icon.type // May be null! + } + } + Log.d(TAG, "New icon size: $size, type: $mimeType") + return icon.copy( + size = size, + type = mimeType + ) + } + + private fun failed(e: Exception) { + Log.w(TAG, "Icon download failed", e) + maybeDeleteFile() + } + + private fun maybeDeleteFile() { + val uriCopy = uri + if (uriCopy != null) { + Log.d(TAG, "Deleting leftover icon $uriCopy") + val resolver = applicationContext.contentResolver + resolver.delete(uriCopy, null, null) + } + } + + private fun save(newIcon: Icon) { + Log.d(TAG, "Updating icon: $newIcon") + icon = newIcon + notification = notification.copy(icon = newIcon) + notifier.update(subscription, notification) + repository.updateNotification(notification) + } + + private fun shouldAbortDownload(): Boolean { + val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE + val size = icon.size ?: return false // Don't abort if size unknown + return size > maxAutoDownloadSize + } + + private fun createUri(notification: Notification): Uri { + val iconDir = File(context.cacheDir, ICON_CACHE_DIR) + if (!iconDir.exists() && !iconDir.mkdirs()) { + throw Exception("Cannot create cache directory for icons: $iconDir") + } + val file = ensureSafeNewFile(iconDir, notification.id) + return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + } + + companion object { + const val INPUT_DATA_ID = "id" + const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml + const val MAX_ICON_DOWNLOAD_SIZE = 300000 + + private const val TAG = "NtfyIconDownload" + private const val ICON_CACHE_DIR = "icons" + private const val BUFFER_SIZE = 8 * 1024 + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt index 4a6739c..68cfb85 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt @@ -15,25 +15,74 @@ import io.heckel.ntfy.util.Log */ object DownloadManager { private const val TAG = "NtfyDownloadManager" - private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_" + private const val DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_" + private const val DOWNLOAD_WORK_ICON_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_ICON_" + private const val DOWNLOAD_WORK_BOTH_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_BOTH_" - fun enqueue(context: Context, notificationId: String, userAction: Boolean) { + fun enqueue(context: Context, notificationId: String, userAction: Boolean, type: DownloadType) { + when (type) { + DownloadType.ATTACHMENT -> enqueueAttachment(context, notificationId, userAction) + DownloadType.ICON -> enqueueIcon(context, notificationId) + DownloadType.BOTH -> enqueueAttachmentAndIcon(context, notificationId, userAction) + else -> Log.w(DownloadManager.TAG, "This should never happen. No download type given") + } + } + + private fun enqueueAttachment(context: Context, notificationId: String, userAction: Boolean) { val workManager = WorkManager.getInstance(context) - val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId + val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + notificationId Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName") - val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java) + val workRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java) .setInputData(workDataOf( - DownloadWorker.INPUT_DATA_ID to notificationId, - DownloadWorker.INPUT_DATA_USER_ACTION to userAction + DownloadAttachmentWorker.INPUT_DATA_ID to notificationId, + DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction )) .build() workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest) } + private fun enqueueIcon(context: Context, notificationId: String) { + val workManager = WorkManager.getInstance(context) + val workName = DOWNLOAD_WORK_ICON_NAME_PREFIX + notificationId + Log.d(TAG,"Enqueuing work to download icon for notification $notificationId, work: $workName") + val workRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java) + .setInputData(workDataOf( + DownloadAttachmentWorker.INPUT_DATA_ID to notificationId + )) + .build() + workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest) + } + + private fun enqueueAttachmentAndIcon(context: Context, notificationId: String, userAction: Boolean) { + val workManager = WorkManager.getInstance(context) + val workName = DOWNLOAD_WORK_BOTH_NAME_PREFIX + notificationId + val attachmentWorkRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java) + .setInputData(workDataOf( + DownloadAttachmentWorker.INPUT_DATA_ID to notificationId, + DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction + )) + .build() + val iconWorkRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java) + .setInputData(workDataOf( + DownloadAttachmentWorker.INPUT_DATA_ID to notificationId + )) + .build() + Log.d(TAG,"Enqueuing work to download both attachment and icon for notification $notificationId, work: $workName") + workManager.beginUniqueWork(workName, ExistingWorkPolicy.KEEP, attachmentWorkRequest) + .then(iconWorkRequest) + .enqueue() + } + fun cancel(context: Context, id: String) { val workManager = WorkManager.getInstance(context) - val workName = DOWNLOAD_WORK_NAME_PREFIX + id - Log.d(TAG, "Cancelling download for notification $id, work: $workName") + val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + id + Log.d(TAG, "Cancelling attachment download for notification $id, work: $workName") workManager.cancelUniqueWork(workName) } } + +enum class DownloadType { + ATTACHMENT, + ICON, + BOTH +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 04289ae..bc3799c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -13,6 +13,7 @@ data class Message( val priority: Int?, val tags: List?, val click: String?, + val icon: MessageIcon?, val actions: List?, val title: String?, val message: String, @@ -43,4 +44,11 @@ data class MessageAction( val extras: Map?, // used in "broadcast" action ) +@Keep +data class MessageIcon( + val url: String, + val type: String?, + val size: Long?, +) + const val MESSAGE_ENCODING_BASE64 = "base64" 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 2aec2c1..88593f7 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -29,7 +29,8 @@ 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(notification) + val downloadAttachment = shouldDownloadAttachment(notification) + val downloadIcon = shouldDownloadIcon(notification) if (notify) { notifier.display(subscription, notification) } @@ -41,12 +42,16 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification)) } } - if (download) { - DownloadManager.enqueue(context, notification.id, userAction = false) + if (downloadAttachment && downloadIcon) { + DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.BOTH) + } else if (downloadAttachment) { + DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ATTACHMENT) + } else if (downloadIcon) { + DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ICON) } } - private fun shouldDownload(notification: Notification): Boolean { + private fun shouldDownloadAttachment(notification: Notification): Boolean { if (notification.attachment == null) { return false } @@ -67,6 +72,17 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } } + private fun shouldDownloadIcon(notification: Notification): Boolean { + if (notification.icon == null) { + return false + } + val icon = notification.icon + val maxIconDownloadSize = DownloadIconWorker.MAX_ICON_DOWNLOAD_SIZE + if (icon.size == null) { + return true // DownloadWorker will bail out if attachment is too large! + } + return icon.size <= maxIconDownloadSize + } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { if (subscription.upAppId != null) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 0faaf23..644cc47 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.heckel.ntfy.db.Action import io.heckel.ntfy.db.Attachment +import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.joinTags import io.heckel.ntfy.util.toPriority @@ -31,6 +32,13 @@ class NotificationParser { url = message.attachment.url, ) } else null + val icon = if (message.icon?.url != null) { + Icon( + url = message.icon.url, + type = message.icon.type, + size = message.icon.size, + ) + } else null val actions = if (message.actions != null) { message.actions.map { a -> Action( @@ -59,6 +67,7 @@ class NotificationParser { priority = toPriority(message.priority), tags = joinTags(message.tags), click = message.click ?: "", + icon = icon, actions = actions, attachment = attachment, notificationId = notificationId, 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 846e10b..eb6dec6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -96,6 +96,8 @@ class NotificationService(val context: Context) { val contentUri = notification.attachment?.contentUri val isSupportedImage = supportedImage(notification.attachment?.type) val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null + val notificationIcon = if (notification.icon != null && supportedImage(notification.icon.type)) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null + val largeIcon = notificationIcon ?: subscriptionIcon if (contentUri != null && isSupportedImage) { try { val attachmentBitmap = contentUri.readBitmapFromUri(context) @@ -104,7 +106,7 @@ class NotificationService(val context: Context) { .setLargeIcon(attachmentBitmap) .setStyle(NotificationCompat.BigPictureStyle() .bigPicture(attachmentBitmap) - .bigLargeIcon(subscriptionIcon)) // May be null + .bigLargeIcon(largeIcon)) // May be null } catch (_: Exception) { val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification) builder @@ -116,7 +118,7 @@ class NotificationService(val context: Context) { builder .setContentText(message) .setStyle(NotificationCompat.BigTextStyle().bigText(message)) - .setLargeIcon(subscriptionIcon) // May be null + .setLargeIcon(largeIcon) // May be null } } @@ -275,7 +277,7 @@ class NotificationService(val context: Context) { /** * Receives the broadcast from - * - the "http" and "broadcast" action button (the "view" actio is handled differently) + * - the "http" and "broadcast" action button (the "view" action is handled differently) * - the "download"/"cancel" action button * * Then queues a Worker via WorkManager to execute the action in the background @@ -285,7 +287,7 @@ class NotificationService(val context: Context) { val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return when (type) { - BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true) + BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true, DownloadType.ATTACHMENT) BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId) BROADCAST_TYPE_USER_ACTION -> { val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index de1ea77..46c0925 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -16,7 +16,6 @@ import android.widget.* import androidx.cardview.widget.CardView import androidx.constraintlayout.helper.widget.Flow import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintProperties.WRAP_CONTENT import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -29,7 +28,8 @@ import com.stfalcon.imageviewer.StfalconImageViewer import io.heckel.ntfy.R import io.heckel.ntfy.db.* import io.heckel.ntfy.msg.DownloadManager -import io.heckel.ntfy.msg.DownloadWorker +import io.heckel.ntfy.msg.DownloadAttachmentWorker +import io.heckel.ntfy.msg.DownloadType import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.* @@ -389,7 +389,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream") val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name) - FileProvider.getUriForFile(context, DownloadWorker.FILE_PROVIDER_AUTHORITY, file) + FileProvider.getUriForFile(context, DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file) } else { val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content") @@ -443,7 +443,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD) return true } - DownloadManager.enqueue(context, notification.id, userAction = true) + DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT) return true } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index 5082708..91b41d1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -4,7 +4,6 @@ import android.content.ContentResolver import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import android.text.TextUtils @@ -21,7 +20,7 @@ import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription -import io.heckel.ntfy.msg.DownloadWorker +import io.heckel.ntfy.msg.DownloadAttachmentWorker import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.* import kotlinx.coroutines.* @@ -396,7 +395,7 @@ class DetailSettingsActivity : AppCompatActivity() { return null } val file = File(dir, subscription.id.toString()) - return FileProvider.getUriForFile(requireContext(), DownloadWorker.FILE_PROVIDER_AUTHORITY, file) + return FileProvider.getUriForFile(requireContext(), DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file) } private fun deleteIcon(uri: String?) { diff --git a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt index b9d7cbe..b5706d7 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -27,6 +27,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx override suspend fun doWork(): Result { return withContext(Dispatchers.IO) { + deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredNotifications() return@withContext Result.success() @@ -59,6 +60,31 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx } } + private fun deleteExpiredIcons() { + Log.d(TAG, "Deleting icons for deleted notifications") + val resolver = applicationContext.contentResolver + val repository = Repository.getInstance(applicationContext) + val notifications = repository.getDeletedNotificationsWithIcons() + notifications.forEach { notification -> + try { + val icon = notification.icon ?: return + val contentUri = Uri.parse(icon.contentUri ?: return) + Log.d(TAG, "Deleting icon for notification ${notification.id}: ${icon.contentUri} (${icon.url})") + val deleted = resolver.delete(contentUri, null, null) > 0 + if (!deleted) { + Log.w(TAG, "Unable to delete icon for notification ${notification.id}") + } + val newIcon = icon.copy( + contentUri = null, + ) + val newNotification = notification.copy(icon = newIcon) + repository.updateNotification(newNotification) + } catch (e: Exception) { + Log.w(TAG, "Failed to delete icon for notification: ${e.message}", e) + } + } + } + private suspend fun deleteExpiredNotifications() { Log.d(TAG, "Deleting expired notifications") val repository = Repository.getInstance(applicationContext) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 466cd40..2e46612 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,6 +165,7 @@ download failed download failed, link expired download failed, link expires %1$s + Could not download icon: %1$s Notifications on