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)