Loading app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +3 −4 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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) Loading app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +24 −17 Original line number Diff line number Diff line Loading @@ -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) } Loading Loading @@ -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 } Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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<Notification>() { Loading @@ -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." } } app/src/main/java/io/heckel/ntfy/util/Util.kt +12 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, Loading app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +1 −3 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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) Loading Loading @@ -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) } } Loading fastlane/metadata/android/en-US/changelog/28.txt +2 −0 Original line number Diff line number Diff line Loading @@ -4,11 +4,13 @@ Features: * Polling is now done with since=<id> 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) Loading Loading
app/src/main/java/io/heckel/ntfy/msg/DownloadIconWorker.kt +3 −4 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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) Loading
app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +24 −17 Original line number Diff line number Diff line Loading @@ -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) } Loading Loading @@ -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 } Loading @@ -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 Loading Loading @@ -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 { Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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<Notification>() { Loading @@ -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." } }
app/src/main/java/io/heckel/ntfy/util/Util.kt +12 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, Loading
app/src/main/java/io/heckel/ntfy/work/DeleteWorker.kt +1 −3 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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) Loading Loading @@ -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) } } Loading
fastlane/metadata/android/en-US/changelog/28.txt +2 −0 Original line number Diff line number Diff line Loading @@ -4,11 +4,13 @@ Features: * Polling is now done with since=<id> 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) Loading