From ecefdd3df6d75893e0f6b430b23154b5b55e6ed8 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 16 Jul 2022 14:32:09 -0600 Subject: [PATCH 01/14] 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 From 12bd524cbd6742cfc047310744a988a6128929c8 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 17 Jul 2022 15:40:51 -0600 Subject: [PATCH 02/14] change icon in message from object to string --- app/src/main/java/io/heckel/ntfy/msg/Message.kt | 9 +-------- .../java/io/heckel/ntfy/msg/NotificationParser.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 15 deletions(-) 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 bc3799c..8de0ab8 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -13,7 +13,7 @@ data class Message( val priority: Int?, val tags: List?, val click: String?, - val icon: MessageIcon?, + val icon: String?, val actions: List?, val title: String?, val message: String, @@ -44,11 +44,4 @@ 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/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 644cc47..d26f5d0 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -32,13 +32,6 @@ 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( @@ -57,6 +50,13 @@ class NotificationParser { ) } } else null + val icon: Icon? = if (message.icon != null) { + Icon( + url = message.icon, + size = null, + type = null + ) + } else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, From a2ae6e4c21c1076e23e6ce313ad87a623fa71a72 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Thu, 25 Aug 2022 21:58:37 -0600 Subject: [PATCH 03/14] remove icon size and type --- .../io.heckel.ntfy.db.Database/11.json | 44 ++---------------- .../io.heckel.ntfy.db.Database/12.json | 18 ++------ .../java/io/heckel/ntfy/backup/Backuper.kt | 7 --- .../main/java/io/heckel/ntfy/db/Database.kt | 8 +--- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 45 +++---------------- .../heckel/ntfy/msg/NotificationDispatcher.kt | 13 +----- .../io/heckel/ntfy/msg/NotificationParser.kt | 4 +- .../io/heckel/ntfy/msg/NotificationService.kt | 2 +- 8 files changed, 18 insertions(+), 123 deletions(-) diff --git a/app/schemas/io.heckel.ntfy.db.Database/11.json b/app/schemas/io.heckel.ntfy.db.Database/11.json index 8d8e064..b54336c 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": "5a061926458ed65c80431be0a69a2450", + "identityHash": "31f8e6a2032d1d404fad4307abf23e1b", "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, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` 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, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -50,12 +50,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "lastNotificationId", - "columnName": "lastNotificationId", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "icon", "columnName": "icon", @@ -73,12 +67,6 @@ "columnName": "upConnectorToken", "affinity": "TEXT", "notNull": false - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": false } ], "primaryKey": { @@ -112,7 +100,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, `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`))", + "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`))", "fields": [ { "fieldPath": "id", @@ -187,30 +175,6 @@ "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", @@ -350,7 +314,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, '5a061926458ed65c80431be0a69a2450')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '31f8e6a2032d1d404fad4307abf23e1b')" ] } } \ 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 31f125c..e9e36a1 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": "5a061926458ed65c80431be0a69a2450", + "identityHash": "d230005f4d9824ba9aa34c61003bdcbb", "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, `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`))", + "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_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", @@ -193,18 +193,6 @@ "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", @@ -350,7 +338,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, '5a061926458ed65c80431be0a69a2450')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd230005f4d9824ba9aa34c61003bdcbb')" ] } } \ 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 49c6464..e0f8f92 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -152,8 +152,6 @@ class Backuper(val context: Context) { 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 { @@ -281,8 +279,6 @@ class Backuper(val context: Context) { val icon = if (n.icon != null) { Icon( url = n.icon.url, - type = n.icon.type, - size = n.icon.size, contentUri = n.icon.contentUri, ) } else { @@ -403,10 +399,7 @@ data class Attachment( 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( 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 c56fa95..e267a45 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -95,12 +95,10 @@ 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) + constructor(url:String) : + this(url, null) } @Entity @@ -282,8 +280,6 @@ abstract class Database : RoomDatabase() { 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") } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index e4535a5..2521f43 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -23,7 +23,7 @@ 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 + .callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) @@ -69,8 +69,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) if (!response.isSuccessful || response.body == null) { throw Exception("Unexpected response: ${response.code}") } - save(updateIconFromResponse(response)) - if (shouldAbortDownload()) { + if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") return } @@ -85,7 +84,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) val downloadLimit = if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { repository.getAutoDownloadMaxSize() } else { - null + MAX_ICON_DOWNLOAD_SIZE.toLong() } outFile.use { fileOut -> val fileIn = response.body!!.byteStream() @@ -102,48 +101,14 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) } 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() @@ -166,9 +131,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) repository.updateNotification(notification) } - private fun shouldAbortDownload(): Boolean { + private fun shouldAbortDownload(response: Response): Boolean { val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE - val size = icon.size ?: return false // Don't abort if size unknown + val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown return size > maxAutoDownloadSize } 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 88593f7..c763b44 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -60,8 +60,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading") return false } - val maxAutoDownloadSize = repository.getAutoDownloadMaxSize() - when (maxAutoDownloadSize) { + when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) { Repository.AUTO_DOWNLOAD_ALWAYS -> return true Repository.AUTO_DOWNLOAD_NEVER -> return false else -> { @@ -73,15 +72,7 @@ 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 + return notification.icon != null } private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { 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 d26f5d0..877a3be 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -52,9 +52,7 @@ class NotificationParser { } else null val icon: Icon? = if (message.icon != null) { Icon( - url = message.icon, - size = null, - type = null + url = message.icon ) } else null val notification = Notification( 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 eb6dec6..a35ff8d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -96,7 +96,7 @@ 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 notificationIcon = if (notification.icon != null) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null val largeIcon = notificationIcon ?: subscriptionIcon if (contentUri != null && isSupportedImage) { try { From c7edb50ebc21a226287d486a49166cf07727db70 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 27 Aug 2022 12:11:27 -0600 Subject: [PATCH 04/14] cache notification icons and delete after 24 hours --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 30 ++++++++++++++----- app/src/main/java/io/heckel/ntfy/util/Util.kt | 8 +++++ .../java/io/heckel/ntfy/work/DeleteWorker.kt | 21 +++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 2521f43..aa74157 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -14,7 +14,7 @@ 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 io.heckel.ntfy.util.stringToHash import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -44,7 +44,17 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure() icon = notification.icon ?: return Result.failure() try { - downloadIcon() + val iconFile = createIconFile(icon) + if (!iconFile.exists()) { + downloadIcon(iconFile) + } else { + Log.d(TAG, "Loading icon from cache: ${icon.url}") + val iconUri = createIconUri(iconFile) + this.uri = iconUri // Required for cleanup in onStopped() + save(icon.copy( + contentUri = iconUri.toString() + )) + } } catch (e: Exception) { failed(e) } @@ -56,7 +66,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) maybeDeleteFile() } - private fun downloadIcon() { + private fun downloadIcon(iconFile: File) { Log.d(TAG, "Downloading icon from ${icon.url}") try { @@ -74,7 +84,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) return } val resolver = applicationContext.contentResolver - val uri = createUri(notification) + val uri = createIconUri(iconFile) this.uri = uri // Required for cleanup in onStopped() Log.d(TAG, "Starting download to content URI: $uri") @@ -137,13 +147,17 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) return size > maxAutoDownloadSize } - private fun createUri(notification: Notification): Uri { + private fun createIconFile(icon: Icon): File { 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) + val hash = stringToHash(icon.url) + return File(iconDir, hash) + } + + private fun createIconUri(iconFile: File): Uri { + return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile) } companion object { @@ -152,7 +166,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) const val MAX_ICON_DOWNLOAD_SIZE = 300000 private const val TAG = "NtfyIconDownload" - private const val ICON_CACHE_DIR = "icons" + const val ICON_CACHE_DIR = "icons" private const val BUFFER_SIZE = 8 * 1024 } } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index a6a3197..59d1df3 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -38,6 +38,7 @@ import okhttp3.RequestBody import okio.BufferedSink import okio.source import java.io.* +import java.security.MessageDigest import java.security.SecureRandom import java.text.DateFormat import java.text.StringCharacterIterator @@ -469,3 +470,10 @@ fun copyToClipboard(context: Context, notification: Notification) { .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .show() } + +fun stringToHash(s: String): String { + val bytes = s.toByteArray(); + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return digest.fold("") { str, it -> str + "%02x".format(it) } +} \ No newline at end of file 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 b5706d7..ddeee83 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -7,11 +7,14 @@ import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.msg.DownloadIconWorker import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.topicShortUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File +import java.util.* /** * Deletes notifications marked for deletion and attachments for deleted notifications. @@ -30,6 +33,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredNotifications() + cleanIconCache() return@withContext Result.success() } } @@ -85,6 +89,23 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx } } + private fun cleanIconCache() { + Log.d(DeleteWorker.TAG, "Cleaning icons older than 24 hours from cache") + val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) + if (iconDir.exists()) { + for (f: File in iconDir.listFiles()) { + var lastModified = f.lastModified() + var today = Date() + + var diffInHours = ((today.time - lastModified) / (1000 * 60 * 60)) + if (diffInHours > 24) { + Log.d(DeleteWorker.TAG, "Deleting cached icon: ${f.name}") + f.delete() + } + } + } + } + private suspend fun deleteExpiredNotifications() { Log.d(TAG, "Deleting expired notifications") val repository = Repository.getInstance(applicationContext) From f68bb5f379d2a9a0d9e37ad9505181fa4582da6c Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 28 Aug 2022 14:52:35 -0600 Subject: [PATCH 05/14] delete unreferenced icons periodically and download updates every 24 hours --- .../main/java/io/heckel/ntfy/db/Database.kt | 10 ++++- .../main/java/io/heckel/ntfy/db/Repository.kt | 12 +++++- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 8 ++-- .../io/heckel/ntfy/msg/NotificationParser.kt | 6 +-- app/src/main/java/io/heckel/ntfy/util/Util.kt | 5 +-- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 40 +++++-------------- app/src/main/res/values/strings.xml | 1 - 7 files changed, 35 insertions(+), 47 deletions(-) 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 e267a45..3ab4ca5 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -380,8 +380,14 @@ 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 + @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''") + fun listActiveIconUris(): List + + @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") + fun listDeletedIconUris(): List + + @Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri") + fun clearIconUri(uri: String) @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 ed45597..a39f617 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -92,8 +92,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listDeletedWithAttachments() } - fun getDeletedNotificationsWithIcons(): List { - return notificationDao.listDeletedWithIcons() + fun getActiveIconUris(): Set { + return notificationDao.listActiveIconUris().toSet() + } + + fun getDeletedIconUris(): Set { + return notificationDao.listDeletedIconUris().toSet() + } + + fun clearIconUri(uri: String) { + notificationDao.clearIconUri(uri) } fun getNotificationsLiveData(subscriptionId: Long): LiveData> { diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index aa74157..9242743 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -14,11 +14,12 @@ 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.stringToHash +import io.heckel.ntfy.util.sha256 import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import java.io.File +import java.util.Date import java.util.concurrent.TimeUnit class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { @@ -45,7 +46,8 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) icon = notification.icon ?: return Result.failure() try { val iconFile = createIconFile(icon) - if (!iconFile.exists()) { + val yesterdayTimestamp = Date().time - 1000*60*60*24 // now Unix timestamp - 24 hours + if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { downloadIcon(iconFile) } else { Log.d(TAG, "Loading icon from cache: ${icon.url}") @@ -152,7 +154,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) if (!iconDir.exists() && !iconDir.mkdirs()) { throw Exception("Cannot create cache directory for icons: $iconDir") } - val hash = stringToHash(icon.url) + val hash = icon.url.sha256() return File(iconDir, hash) } 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 877a3be..60b2e3a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -50,11 +50,7 @@ class NotificationParser { ) } } else null - val icon: Icon? = if (message.icon != null) { - Icon( - url = message.icon - ) - } else null + val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 59d1df3..a3702e1 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -471,9 +471,8 @@ fun copyToClipboard(context: Context, notification: Notification) { .show() } -fun stringToHash(s: String): String { - val bytes = s.toByteArray(); +fun String.sha256(): String { val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(bytes) + val digest = md.digest(this.toByteArray()) return digest.fold("") { str, it -> str + "%02x".format(it) } } \ No newline at end of file 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 ddeee83..7d611cb 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -33,7 +33,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx deleteExpiredIcons() // Before notifications, so we will also catch manually deleted notifications deleteExpiredAttachments() // Before notifications, so we will also catch manually deleted notifications deleteExpiredNotifications() - cleanIconCache() return@withContext Result.success() } } @@ -68,40 +67,19 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx Log.d(TAG, "Deleting icons for deleted notifications") val resolver = applicationContext.contentResolver val repository = Repository.getInstance(applicationContext) - val notifications = repository.getDeletedNotificationsWithIcons() - notifications.forEach { notification -> + val activeIconUris = repository.getActiveIconUris() + val expiredIconUris = repository.getDeletedIconUris() + val urisToDelete = expiredIconUris.minus(activeIconUris) + urisToDelete.forEach { uri -> 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 + val deleted = resolver.delete(Uri.parse(uri), null, null) > 0 if (!deleted) { - Log.w(TAG, "Unable to delete icon for notification ${notification.id}") + Log.w(TAG, "Unable to delete icon at $uri") } - val newIcon = icon.copy( - contentUri = null, - ) - val newNotification = notification.copy(icon = newIcon) - repository.updateNotification(newNotification) + + repository.clearIconUri(uri) } catch (e: Exception) { - Log.w(TAG, "Failed to delete icon for notification: ${e.message}", e) - } - } - } - - private fun cleanIconCache() { - Log.d(DeleteWorker.TAG, "Cleaning icons older than 24 hours from cache") - val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) - if (iconDir.exists()) { - for (f: File in iconDir.listFiles()) { - var lastModified = f.lastModified() - var today = Date() - - var diffInHours = ((today.time - lastModified) / (1000 * 60 * 60)) - if (diffInHours > 24) { - Log.d(DeleteWorker.TAG, "Deleting cached icon: ${f.name}") - f.delete() - } + Log.w(TAG, "Failed to delete icon: ${e.message}", e) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e46612..466cd40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,7 +165,6 @@ download failed download failed, link expired download failed, link expires %1$s - Could not download icon: %1$s Notifications on From 80cc0118527ee8387d8f2c7ba331cc485ddb35ef Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 28 Aug 2022 20:34:39 -0600 Subject: [PATCH 06/14] improved icon cache clean up --- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 7d611cb..8e8923f 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.work import android.content.Context import android.net.Uri +import androidx.core.content.FileProvider import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig @@ -10,6 +11,7 @@ import io.heckel.ntfy.db.Repository import io.heckel.ntfy.msg.DownloadIconWorker import io.heckel.ntfy.ui.DetailAdapter import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.fileStat import io.heckel.ntfy.util.topicShortUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,18 +67,22 @@ 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 activeIconUris = repository.getActiveIconUris() - val expiredIconUris = repository.getDeletedIconUris() - val urisToDelete = expiredIconUris.minus(activeIconUris) - urisToDelete.forEach { uri -> + val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet() + val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) + val allIconFilenames = iconDir.listFiles().map{ file -> file.name } + val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) + filenamesToDelete.forEach { filename -> try { - val deleted = resolver.delete(Uri.parse(uri), null, null) > 0 + val file = File(iconDir, filename) + val deleted = file.delete() if (!deleted) { - Log.w(TAG, "Unable to delete icon at $uri") + Log.w(TAG, "Unable to delete icon: $filename") } + val uri = FileProvider.getUriForFile(applicationContext, + DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString() repository.clearIconUri(uri) } catch (e: Exception) { Log.w(TAG, "Failed to delete icon: ${e.message}", e) From fcce44bef109075d8cab59131837633293559e67 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 5 Sep 2022 16:52:26 -0600 Subject: [PATCH 07/14] clean up old db queries and DownloadIconWorker --- app/src/main/java/io/heckel/ntfy/db/Database.kt | 3 --- .../main/java/io/heckel/ntfy/db/Repository.kt | 4 ---- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 17 ++++++++++------- 3 files changed, 10 insertions(+), 14 deletions(-) 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 3ab4ca5..62daf0a 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -383,9 +383,6 @@ interface NotificationDao { @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''") fun listActiveIconUris(): List - @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted = 1 AND icon_contentUri <> ''") - fun listDeletedIconUris(): List - @Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri") fun clearIconUri(uri: String) 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 a39f617..cee2985 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -96,10 +96,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listActiveIconUris().toSet() } - fun getDeletedIconUris(): Set { - return notificationDao.listDeletedIconUris().toSet() - } - fun clearIconUri(uri: String) { notificationDao.clearIconUri(uri) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 9242743..9da973c 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -90,14 +90,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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 { - MAX_ICON_DOWNLOAD_SIZE.toLong() - } + val downloadLimit = getDownloadLimit() outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -144,11 +139,19 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) } private fun shouldAbortDownload(response: Response): Boolean { - val maxAutoDownloadSize = MAX_ICON_DOWNLOAD_SIZE + val maxAutoDownloadSize = getDownloadLimit() val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown return size > maxAutoDownloadSize } + private fun getDownloadLimit(): Long { + return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { + repository.getAutoDownloadMaxSize() + } else { + MAX_ICON_DOWNLOAD_SIZE.toLong() + } + } + private fun createIconFile(icon: Icon): File { val iconDir = File(context.cacheDir, ICON_CACHE_DIR) if (!iconDir.exists() && !iconDir.mkdirs()) { From 433fad761a7f3777e8a622f01c2428606d6eaaf7 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Tue, 6 Sep 2022 21:42:40 -0600 Subject: [PATCH 08/14] remove attachment download limit if userAction --- .../io/heckel/ntfy/msg/DownloadAttachmentWorker.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt index bc22e9d..cffd591 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadAttachmentWorker.kt @@ -82,11 +82,7 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam Log.d(TAG, "Starting download to content URI: $uri") 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 - } + val downloadLimit = getDownloadLimit(userAction) outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -192,6 +188,14 @@ class DownloadAttachmentWorker(private val context: Context, params: WorkerParam } } + private fun getDownloadLimit(userAction: Boolean): Long? { + return if (userAction || repository.getAutoDownloadMaxSize() == Repository.AUTO_DOWNLOAD_ALWAYS) { + null + } else { + repository.getAutoDownloadMaxSize() + } + } + private fun createUri(notification: Notification): Uri { val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR) if (!attachmentDir.exists() && !attachmentDir.mkdirs()) { From 35ea6de6f89697c9bb9ebf31166101ed2efd150e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 7 Sep 2022 16:51:09 -0400 Subject: [PATCH 09/14] Fix FirebaseService --- .../play/java/io/heckel/ntfy/firebase/FirebaseService.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 ec1e130..8e200bc 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -1,17 +1,16 @@ package io.heckel.ntfy.firebase import android.content.Intent -import android.util.Base64 import androidx.work.* 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.db.Attachment +import io.heckel.ntfy.db.Icon import io.heckel.ntfy.db.Notification import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService -import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64 import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.service.SubscriberService @@ -90,6 +89,7 @@ class FirebaseService : FirebaseMessagingService() { val priority = data["priority"]?.toIntOrNull() val tags = data["tags"] val click = data["click"] + val iconUrl = data["icon"] val actions = data["actions"] // JSON array as string, sigh ... val encoding = data["encoding"] val attachmentName = data["attachment_name"] ?: "attachment.bin" @@ -124,6 +124,7 @@ class FirebaseService : FirebaseMessagingService() { url = attachmentUrl, ) } else null + val icon: Icon? = iconUrl?.let { Icon(url = it) } val notification = Notification( id = id, subscriptionId = subscription.id, @@ -134,6 +135,7 @@ class FirebaseService : FirebaseMessagingService() { priority = toPriority(priority), tags = tags ?: "", click = click ?: "", + icon = icon, actions = parser.parseActions(actions), attachment = attachment, notificationId = Random.nextInt(), From f6ce3af473f5fc586a55717545f0a4eb5f10621f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 9 Sep 2022 14:14:03 -0400 Subject: [PATCH 10/14] Layouting icon Ahhhh --- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 44 +++++++++++++------ .../main/res/layout/fragment_detail_item.xml | 15 ++++--- 2 files changed, 40 insertions(+), 19 deletions(-) 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 46c0925..e5de368 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -80,6 +80,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text) private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text) + private val iconView: ImageView = itemView.findViewById(R.id.detail_item_icon) private val newDotImageView: View = itemView.findViewById(R.id.detail_item_new_dot) private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text) private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button) @@ -130,11 +131,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) } val attachment = notification.attachment - val exists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false + val attachmentExists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false + val iconExists = if (notification.icon?.contentUri != null) fileExists(context, notification.icon.contentUri) else false renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, exists) - maybeRenderAttachment(context, notification, exists) + maybeRenderMenu(context, notification, attachmentExists) + maybeRenderAttachment(context, notification, attachmentExists) + maybeRenderIcon(context, notification, iconExists) maybeRenderActions(context, notification) } @@ -162,20 +165,35 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderAttachment(context: Context, notification: Notification, exists: Boolean) { + private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentExists: Boolean) { if (notification.attachment == null) { attachmentImageView.visibility = View.GONE attachmentBoxView.visibility = View.GONE return } val attachment = notification.attachment - val image = attachment.contentUri != null && exists && supportedImage(attachment.type) + val image = attachment.contentUri != null && attachmentExists && supportedImage(attachment.type) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, exists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentExists, image) } - private fun maybeRenderMenu(context: Context, notification: Notification, exists: Boolean) { - val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, exists) // Heavy lifting not during on-click + private fun maybeRenderIcon(context: Context, notification: Notification, iconExists: Boolean) { + if (notification.icon == null || !iconExists) { + iconView.visibility = View.GONE + return + } + try { + val icon = notification.icon + val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty") + iconView.setImageBitmap(bitmap) + iconView.visibility = View.VISIBLE + } catch (_: Exception) { + iconView.visibility = View.GONE + } + } + + private fun maybeRenderMenu(context: Context, notification: Notification, attachmentExists: Boolean) { + val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentExists) // Heavy lifting not during on-click if (menuButtonPopupMenu != null) { menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.visibility = View.VISIBLE @@ -240,7 +258,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: attachmentBoxView.visibility = View.VISIBLE } - private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, exists: Boolean): PopupMenu? { + private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentExists: Boolean): PopupMenu? { val popup = PopupMenu(context, anchor) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) val attachment = notification.attachment // May be null @@ -266,10 +284,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: if (hasClickLink) { copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) } } - openItem.isVisible = hasAttachment && exists - downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress - deleteItem.isVisible = hasAttachment && exists - saveFileItem.isVisible = hasAttachment && exists + openItem.isVisible = hasAttachment && attachmentExists + downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress + deleteItem.isVisible = hasAttachment && attachmentExists + saveFileItem.isVisible = hasAttachment && attachmentExists copyUrlItem.isVisible = hasAttachment && !expired cancelItem.isVisible = hasAttachment && inProgress copyContentsItem.isVisible = notification.click != "" diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3a33599..3d625b9 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -51,9 +51,9 @@ android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp" android:id="@+id/detail_item_menu_button" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="7dp" + android:layout_marginEnd="7dp" android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp" - /> + app:layout_constraintEnd_toStartOf="@id/detail_item_icon"/> + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="12dp"/> + app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="12dp"/> + + From f2492904ea0e4e2d628e9e4ed2fe936086340cd2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 15:24:53 -0400 Subject: [PATCH 11/14] Do not crash preview if icon/attachment too large --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 7 ++-- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 41 +++++++++++-------- app/src/main/java/io/heckel/ntfy/util/Util.kt | 14 ++++++- .../java/io/heckel/ntfy/work/DeleteWorker.kt | 4 +- .../metadata/android/en-US/changelog/28.txt | 2 + 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 9da973c..578176a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -98,7 +98,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) val buffer = ByteArray(BUFFER_SIZE) var bytes = fileIn.read(buffer) while (bytes >= 0) { - if (downloadLimit != null && bytesCopied > downloadLimit) { + if (bytesCopied > downloadLimit) { throw Exception("Icon is longer than max download size.") } fileOut.write(buffer, 0, bytes) @@ -106,10 +106,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) bytes = fileIn.read(buffer) } } + // TODO: Resize icon if >5MB, so it can be previewed. Right now it'll just not be shown. Log.d(TAG, "Icon download: successful response, proceeding with download") - save(icon.copy( - contentUri = uri.toString() - )) + save(icon.copy(contentUri = uri.toString())) } } catch (e: Exception) { failed(e) 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 e5de368..40e167b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -131,13 +131,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) } val attachment = notification.attachment - val attachmentExists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false - val iconExists = if (notification.icon?.contentUri != null) fileExists(context, notification.icon.contentUri) else false + val attachmentFileStat = maybeFileStat(context, attachment?.contentUri) + val iconFileStat = maybeFileStat(context, notification.icon?.contentUri) renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, attachmentExists) - maybeRenderAttachment(context, notification, attachmentExists) - maybeRenderIcon(context, notification, iconExists) + maybeRenderMenu(context, notification, attachmentFileStat) + maybeRenderAttachment(context, notification, attachmentFileStat) + maybeRenderIcon(context, notification, iconFileStat) maybeRenderActions(context, notification) } @@ -165,20 +165,20 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentExists: Boolean) { + private fun maybeRenderAttachment(context: Context, notification: Notification, attachmentFileStat: FileInfo?) { if (notification.attachment == null) { attachmentImageView.visibility = View.GONE attachmentBoxView.visibility = View.GONE return } val attachment = notification.attachment - val image = attachment.contentUri != null && attachmentExists && supportedImage(attachment.type) + val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, attachmentExists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, image) } - private fun maybeRenderIcon(context: Context, notification: Notification, iconExists: Boolean) { - if (notification.icon == null || !iconExists) { + private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) { + if (notification.icon == null || !previewableImage(iconStat)) { iconView.visibility = View.GONE return } @@ -192,8 +192,8 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderMenu(context: Context, notification: Notification, attachmentExists: Boolean) { - val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentExists) // Heavy lifting not during on-click + private fun maybeRenderMenu(context: Context, notification: Notification, attachmentFileStat: FileInfo?) { + val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentFileStat) // Heavy lifting not during on-click if (menuButtonPopupMenu != null) { menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.visibility = View.VISIBLE @@ -238,14 +238,14 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: return button } - private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, exists: Boolean, image: Boolean) { + private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, image: Boolean) { if (image) { attachmentBoxView.visibility = View.GONE return } - attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists) + attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat) attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type)) - val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, exists) // Heavy lifting not during on-click + val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // Heavy lifting not during on-click if (attachmentBoxPopupMenu != null) { attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() } } else { @@ -258,11 +258,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: attachmentBoxView.visibility = View.VISIBLE } - private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentExists: Boolean): PopupMenu? { + private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentFileStat: FileInfo?): PopupMenu? { val popup = PopupMenu(context, anchor) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) val attachment = notification.attachment // May be null val hasAttachment = attachment != null + val attachmentExists = attachmentFileStat != null val hasClickLink = notification.click != "" val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) @@ -300,8 +301,9 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: return popup } - private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { + private fun formatAttachmentDetails(context: Context, attachment: Attachment, attachmentFileStat: FileInfo?): String { val name = attachment.name + val exists = attachmentFileStat != null val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE val downloading = !exists && attachment.progress in 0..99 val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED) @@ -517,6 +519,10 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } context.sendBroadcast(intent) } + + private fun previewableImage(fileStat: FileInfo?): Boolean { + return if (fileStat != null) fileStat.size <= IMAGE_PREVIEW_MAX_BYTES else false + } } object TopicDiffCallback : DiffUtil.ItemCallback() { @@ -532,5 +538,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: companion object { const val TAG = "NtfyDetailAdapter" const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 + const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." } } diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index a3702e1..ca69cdf 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -37,7 +37,9 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody import okio.BufferedSink import okio.source -import java.io.* +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException import java.security.MessageDigest import java.security.SecureRandom import java.text.DateFormat @@ -260,6 +262,14 @@ fun fileStat(context: Context, contentUri: Uri?): FileInfo { } } +fun maybeFileStat(context: Context, contentUri: String?): FileInfo? { + return try { + fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist + } catch (_: Exception) { + null + } +} + data class FileInfo( val filename: String, val size: Long, @@ -475,4 +485,4 @@ fun String.sha256(): String { val md = MessageDigest.getInstance("SHA-256") val digest = md.digest(this.toByteArray()) return digest.fold("") { str, it -> str + "%02x".format(it) } -} \ No newline at end of file +} 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 8e8923f..01fdebd 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -71,7 +71,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx val activeIconUris = repository.getActiveIconUris() val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet() val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) - val allIconFilenames = iconDir.listFiles().map{ file -> file.name } + val allIconFilenames = iconDir.listFiles()?.map{ file -> file.name }.orEmpty() val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) filenamesToDelete.forEach { filename -> try { @@ -80,7 +80,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx if (!deleted) { Log.w(TAG, "Unable to delete icon: $filename") } - val uri = FileProvider.getUriForFile(applicationContext, DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString() repository.clearIconUri(uri) @@ -115,7 +114,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp") repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp) - } } diff --git a/fastlane/metadata/android/en-US/changelog/28.txt b/fastlane/metadata/android/en-US/changelog/28.txt index 27be690..459b0c1 100644 --- a/fastlane/metadata/android/en-US/changelog/28.txt +++ b/fastlane/metadata/android/en-US/changelog/28.txt @@ -4,11 +4,13 @@ Features: * Polling is now done with since= API, which makes deduping easier (#165) * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) * Move action buttons in notification cards (#236, thanks to @wunter8) +* Icons can be set for each individual notification (#126, thanks to @wunter8) Bugs: * Long-click selecting of notifications doesn't scoll to the top anymore (#235, thanks to @wunter8) * Add attachment and click URL extras to MESSAGE_RECEIVED broadcast (#329, thanks to @wunter8) * Accessibility: Clear/choose service URL button in base URL dropdown now has a label (#292, thanks to @mhameed for reporting) +* Do not crash app if preview image too large (no ticket) Additional translations: * Italian (thanks to @Genio2003) From 89d12cb3ced57e05ddf95398423461bec87e0f8a Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 16:06:14 -0400 Subject: [PATCH 12/14] Layouting --- .../main/res/layout/fragment_detail_item.xml | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3d625b9..758e03c 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -25,7 +25,7 @@ android:orientation="horizontal" android:background="?android:attr/selectableItemBackground" android:focusable="true" - android:paddingBottom="6dp" android:paddingTop="6dp"> + android:paddingBottom="6dp" android:paddingTop="6dp" android:paddingEnd="6dp"> + app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="3dp"/> + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp"/> + app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp" tools:layout_constraintEnd_toStartOf="@id/detail_item_icon"/> @@ -193,8 +192,19 @@ app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/> + android:layout_height="0dp" + app:srcCompat="@drawable/ic_notification" + android:id="@+id/detail_item_icon" + android:visibility="visible" + android:maxHeight="40dp" + android:maxWidth="40dp" + android:adjustViewBounds="true" + android:scaleType="fitStart" + android:padding="0dp" + app:layout_constraintTop_toTopOf="@+id/detail_item_date_text" + app:layout_constraintBottom_toBottomOf="@+id/detail_item_message_text" + app:layout_constraintEnd_toStartOf="@id/detail_item_menu_button" + android:layout_marginEnd="6dp"/> From a66fd623748146dfe3f432606fbafe2b639a4be9 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 10 Sep 2022 23:18:11 -0400 Subject: [PATCH 13/14] Limit download size for icons to 5 MB --- .../io/heckel/ntfy/msg/DownloadIconWorker.kt | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt index 578176a..253ee23 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -2,15 +2,10 @@ 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 @@ -46,16 +41,14 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) icon = notification.icon ?: return Result.failure() try { val iconFile = createIconFile(icon) - val yesterdayTimestamp = Date().time - 1000*60*60*24 // now Unix timestamp - 24 hours + val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { downloadIcon(iconFile) } else { - Log.d(TAG, "Loading icon from cache: ${icon.url}") + Log.d(TAG, "Loading icon from cache: $iconFile") val iconUri = createIconUri(iconFile) this.uri = iconUri // Required for cleanup in onStopped() - save(icon.copy( - contentUri = iconUri.toString() - )) + save(icon.copy(contentUri = iconUri.toString())) } } catch (e: Exception) { failed(e) @@ -70,18 +63,16 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) private fun downloadIcon(iconFile: File) { 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") + Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}") if (!response.isSuccessful || response.body == null) { throw Exception("Unexpected response: ${response.code}") - } - if (shouldAbortDownload(response)) { + } else if (shouldAbortDownload(response)) { Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") return } @@ -106,7 +97,6 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) bytes = fileIn.read(buffer) } } - // TODO: Resize icon if >5MB, so it can be previewed. Right now it'll just not be shown. Log.d(TAG, "Icon download: successful response, proceeding with download") save(icon.copy(contentUri = uri.toString())) } @@ -145,9 +135,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) private fun getDownloadLimit(): Long { return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) { - repository.getAutoDownloadMaxSize() + Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES) } else { - MAX_ICON_DOWNLOAD_SIZE.toLong() + DEFAULT_MAX_ICON_DOWNLOAD_BYTES } } @@ -167,10 +157,12 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters) 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 + const val DEFAULT_MAX_ICON_DOWNLOAD_BYTES = 307_200L // 300 KB + const val MAX_ICON_DOWNLOAD_BYTES = 5_242_880L // 5 MB + const val MAX_CACHE_MILLIS = 1000*60*60*24 // 24 hours + const val ICON_CACHE_DIR = "icons" private const val TAG = "NtfyIconDownload" - const val ICON_CACHE_DIR = "icons" private const val BUFFER_SIZE = 8 * 1024 } } From 3e3189fdf2e212247e3b2f25dc9b10949418afc0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 11 Sep 2022 15:28:47 -0400 Subject: [PATCH 14/14] Download icons when adding subscription --- app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 2d7865d..d946fc6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -34,6 +34,8 @@ import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService +import io.heckel.ntfy.msg.DownloadManager +import io.heckel.ntfy.msg.DownloadType import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberServiceManager @@ -456,7 +458,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc try { val user = repository.getUser(subscription.baseUrl) // May be null val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user) - notifications.forEach { notification -> repository.addNotification(notification) } + notifications.forEach { notification -> + repository.addNotification(notification) + if (notification.icon != null) { + DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON) + } + } } catch (e: Exception) { Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) }