Do not crash preview if icon/attachment too large

This commit is contained in:
Philipp Heckel 2022-09-10 15:24:53 -04:00
parent f6ce3af473
commit f2492904ea
5 changed files with 42 additions and 26 deletions

View file

@ -98,7 +98,7 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
var bytes = fileIn.read(buffer) var bytes = fileIn.read(buffer)
while (bytes >= 0) { while (bytes >= 0) {
if (downloadLimit != null && bytesCopied > downloadLimit) { if (bytesCopied > downloadLimit) {
throw Exception("Icon is longer than max download size.") throw Exception("Icon is longer than max download size.")
} }
fileOut.write(buffer, 0, bytes) fileOut.write(buffer, 0, bytes)
@ -106,10 +106,9 @@ class DownloadIconWorker(private val context: Context, params: WorkerParameters)
bytes = fileIn.read(buffer) 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") Log.d(TAG, "Icon download: successful response, proceeding with download")
save(icon.copy( save(icon.copy(contentUri = uri.toString()))
contentUri = uri.toString()
))
} }
} catch (e: Exception) { } catch (e: Exception) {
failed(e) failed(e)

View file

@ -131,13 +131,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context))
} }
val attachment = notification.attachment val attachment = notification.attachment
val attachmentExists = if (attachment?.contentUri != null) fileExists(context, attachment.contentUri) else false val attachmentFileStat = maybeFileStat(context, attachment?.contentUri)
val iconExists = if (notification.icon?.contentUri != null) fileExists(context, notification.icon.contentUri) else false val iconFileStat = maybeFileStat(context, notification.icon?.contentUri)
renderPriority(context, notification) renderPriority(context, notification)
resetCardButtons() resetCardButtons()
maybeRenderMenu(context, notification, attachmentExists) maybeRenderMenu(context, notification, attachmentFileStat)
maybeRenderAttachment(context, notification, attachmentExists) maybeRenderAttachment(context, notification, attachmentFileStat)
maybeRenderIcon(context, notification, iconExists) maybeRenderIcon(context, notification, iconFileStat)
maybeRenderActions(context, notification) 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) { if (notification.attachment == null) {
attachmentImageView.visibility = View.GONE attachmentImageView.visibility = View.GONE
attachmentBoxView.visibility = View.GONE attachmentBoxView.visibility = View.GONE
return return
} }
val attachment = notification.attachment 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) 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) { private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
if (notification.icon == null || !iconExists) { if (notification.icon == null || !previewableImage(iconStat)) {
iconView.visibility = View.GONE iconView.visibility = View.GONE
return return
} }
@ -192,8 +192,8 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
} }
} }
private fun maybeRenderMenu(context: Context, notification: Notification, attachmentExists: Boolean) { private fun maybeRenderMenu(context: Context, notification: Notification, attachmentFileStat: FileInfo?) {
val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentExists) // Heavy lifting not during on-click val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentFileStat) // Heavy lifting not during on-click
if (menuButtonPopupMenu != null) { if (menuButtonPopupMenu != null) {
menuButton.setOnClickListener { menuButtonPopupMenu.show() } menuButton.setOnClickListener { menuButtonPopupMenu.show() }
menuButton.visibility = View.VISIBLE menuButton.visibility = View.VISIBLE
@ -238,14 +238,14 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
return button 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) { if (image) {
attachmentBoxView.visibility = View.GONE attachmentBoxView.visibility = View.GONE
return return
} }
attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists) attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type)) 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) { if (attachmentBoxPopupMenu != null) {
attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() } attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() }
} else { } else {
@ -258,11 +258,12 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
attachmentBoxView.visibility = View.VISIBLE 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) val popup = PopupMenu(context, anchor)
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
val attachment = notification.attachment // May be null val attachment = notification.attachment // May be null
val hasAttachment = attachment != null val hasAttachment = attachment != null
val attachmentExists = attachmentFileStat != null
val hasClickLink = notification.click != "" val hasClickLink = notification.click != ""
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) 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 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 name = attachment.name
val exists = attachmentFileStat != null
val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99 val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED) 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) 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>() { object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {
@ -532,5 +538,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
companion object { companion object {
const val TAG = "NtfyDetailAdapter" const val TAG = "NtfyDetailAdapter"
const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 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."
} }
} }

View file

@ -37,7 +37,9 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source 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.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat 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( data class FileInfo(
val filename: String, val filename: String,
val size: Long, val size: Long,
@ -475,4 +485,4 @@ fun String.sha256(): String {
val md = MessageDigest.getInstance("SHA-256") val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(this.toByteArray()) val digest = md.digest(this.toByteArray())
return digest.fold("") { str, it -> str + "%02x".format(it) } return digest.fold("") { str, it -> str + "%02x".format(it) }
} }

View file

@ -71,7 +71,7 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
val activeIconUris = repository.getActiveIconUris() val activeIconUris = repository.getActiveIconUris()
val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet() val activeIconFilenames = activeIconUris.map{ fileStat(applicationContext, Uri.parse(it)).filename }.toSet()
val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR) 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) val filenamesToDelete = allIconFilenames.minus(activeIconFilenames)
filenamesToDelete.forEach { filename -> filenamesToDelete.forEach { filename ->
try { try {
@ -80,7 +80,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
if (!deleted) { if (!deleted) {
Log.w(TAG, "Unable to delete icon: $filename") Log.w(TAG, "Unable to delete icon: $filename")
} }
val uri = FileProvider.getUriForFile(applicationContext, val uri = FileProvider.getUriForFile(applicationContext,
DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString() DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString()
repository.clearIconUri(uri) repository.clearIconUri(uri)
@ -115,7 +114,6 @@ class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx
val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS
Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp") Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp")
repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp) repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp)
} }
} }

View file

@ -4,11 +4,13 @@ Features:
* Polling is now done with since=<id> API, which makes deduping easier (#165) * Polling is now done with since=<id> API, which makes deduping easier (#165)
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
* Move action buttons in notification cards (#236, thanks to @wunter8) * Move action buttons in notification cards (#236, thanks to @wunter8)
* Icons can be set for each individual notification (#126, thanks to @wunter8)
Bugs: Bugs:
* Long-click selecting of notifications doesn't scoll to the top anymore (#235, thanks to @wunter8) * 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) * 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) * 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: Additional translations:
* Italian (thanks to @Genio2003) * Italian (thanks to @Genio2003)