diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 8b1a443..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": "9363ad5196e88862acceb1bb9ee91124", + "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, `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", @@ -187,6 +187,18 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "attachment.name", "columnName": "attachment_name", @@ -326,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, '9363ad5196e88862acceb1bb9ee91124')" + "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 9ce3069..e0f8f92 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,14 @@ class Backuper(val context: Context) { } else { null } + val icon = if (n.icon != null) { + io.heckel.ntfy.db.Icon( + url = n.icon.url, + contentUri = n.icon.contentUri, + ) + } else { + null + } repository.addNotification(io.heckel.ntfy.db.Notification( id = n.id, subscriptionId = n.subscriptionId, @@ -159,6 +168,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 +276,14 @@ class Backuper(val context: Context) { } else { null } + val icon = if (n.icon != null) { + Icon( + url = n.icon.url, + contentUri = n.icon.contentUri, + ) + } else { + null + } Notification( id = n.id, subscriptionId = n.subscriptionId, @@ -276,6 +294,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 +366,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 +397,11 @@ 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 contentUri: String?, // After it's downloaded, the content:// location +) + 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..62daf0a 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,15 @@ 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 = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location +) { + constructor(url:String) : + this(url, 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 +279,8 @@ 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_contentUri TEXT") } } } @@ -368,6 +380,12 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") fun listDeletedWithAttachments(): List + @Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''") + fun listActiveIconUris(): 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 9f13656..cee2985 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,14 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return notificationDao.listDeletedWithAttachments() } + fun getActiveIconUris(): Set { + return notificationDao.listActiveIconUris().toSet() + } + + fun clearIconUri(uri: String) { + notificationDao.clearIconUri(uri) + } + 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 92% 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..cffd591 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,9 @@ 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 = getDownloadLimit(userAction) outFile.use { fileOut -> val fileIn = response.body!!.byteStream() val buffer = ByteArray(BUFFER_SIZE) @@ -102,8 +102,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,12 +182,20 @@ 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 } } } + 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()) { 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..253ee23 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt @@ -0,0 +1,168 @@ +package io.heckel.ntfy.msg + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.work.Worker +import androidx.work.WorkerParameters +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.db.* +import io.heckel.ntfy.util.Log +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) { + private val client = OkHttpClient.Builder() + .callTimeout(1, 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 { + val iconFile = createIconFile(icon) + val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS + if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) { + downloadIcon(iconFile) + } else { + 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())) + } + } catch (e: Exception) { + failed(e) + } + return Result.success() + } + + override fun onStopped() { + Log.d(TAG, "Icon download was canceled") + maybeDeleteFile() + } + + 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, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}") + if (!response.isSuccessful || response.body == null) { + throw Exception("Unexpected response: ${response.code}") + } else if (shouldAbortDownload(response)) { + Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting") + return + } + val resolver = applicationContext.contentResolver + val uri = createIconUri(iconFile) + this.uri = uri // Required for cleanup in onStopped() + + 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 = getDownloadLimit() + outFile.use { fileOut -> + val fileIn = response.body!!.byteStream() + val buffer = ByteArray(BUFFER_SIZE) + var bytes = fileIn.read(buffer) + while (bytes >= 0) { + if (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(contentUri = uri.toString())) + } + } catch (e: Exception) { + failed(e) + } + } + + 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(response: Response): Boolean { + 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) { + Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES) + } else { + DEFAULT_MAX_ICON_DOWNLOAD_BYTES + } + } + + 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 hash = icon.url.sha256() + return File(iconDir, hash) + } + + private fun createIconUri(iconFile: File): Uri { + return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile) + } + + companion object { + const val INPUT_DATA_ID = "id" + const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml + 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" + 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..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,6 +13,7 @@ data class Message( val priority: Int?, val tags: List?, val click: String?, + val icon: String?, val actions: List?, val title: String?, val message: String, 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..c763b44 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 } @@ -55,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 -> { @@ -67,6 +71,9 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } } + private fun shouldDownloadIcon(notification: Notification): Boolean { + return notification.icon != null + } 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..60b2e3a 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 @@ -49,6 +50,7 @@ class NotificationParser { ) } } else null + val icon: Icon? = if (message.icon != null) Icon(url = message.icon) else null val notification = Notification( id = message.id, subscriptionId = subscriptionId, @@ -59,6 +61,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..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,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) 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..40e167b 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.* @@ -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 attachmentFileStat = maybeFileStat(context, attachment?.contentUri) + val iconFileStat = maybeFileStat(context, notification.icon?.contentUri) renderPriority(context, notification) resetCardButtons() - maybeRenderMenu(context, notification, exists) - maybeRenderAttachment(context, notification, exists) + maybeRenderMenu(context, notification, attachmentFileStat) + maybeRenderAttachment(context, notification, attachmentFileStat) + maybeRenderIcon(context, notification, iconFileStat) 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, attachmentFileStat: FileInfo?) { 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 && supportedImage(attachment.type) && previewableImage(attachmentFileStat) maybeRenderAttachmentImage(context, attachment, image) - maybeRenderAttachmentBox(context, notification, attachment, exists, image) + maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, 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, iconStat: FileInfo?) { + if (notification.icon == null || !previewableImage(iconStat)) { + 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, 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 @@ -220,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 { @@ -240,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, exists: 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) @@ -266,10 +285,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 != "" @@ -282,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) @@ -389,7 +409,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 +463,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 } @@ -499,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() { @@ -514,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/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/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) } 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..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,10 @@ 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 import java.text.StringCharacterIterator @@ -259,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, @@ -469,3 +480,9 @@ fun copyToClipboard(context: Context, notification: Notification) { .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .show() } + +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) } +} 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..01fdebd 100644 --- a/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt @@ -2,16 +2,21 @@ 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 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.fileStat 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. @@ -27,6 +32,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 +65,30 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx } } + private fun deleteExpiredIcons() { + Log.d(TAG, "Deleting icons for deleted notifications") + val repository = Repository.getInstance(applicationContext) + 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 }.orEmpty() + val filenamesToDelete = allIconFilenames.minus(activeIconFilenames) + filenamesToDelete.forEach { filename -> + try { + val file = File(iconDir, filename) + val deleted = file.delete() + if (!deleted) { + 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) + } + } + } + private suspend fun deleteExpiredNotifications() { Log.d(TAG, "Deleting expired notifications") val repository = Repository.getInstance(applicationContext) @@ -84,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/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index 3a33599..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,5 +190,21 @@ android:id="@+id/detail_item_padding_bottom" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/> + + 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(), 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)