diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 3be8c71..58d8511 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -59,15 +59,15 @@ data class Notification( @Entity data class Attachment( - @ColumnInfo(name = "name") val name: String?, // Filename + @ColumnInfo(name = "name") val name: String, // Filename (mandatory, see ntfy server) @ColumnInfo(name = "type") val type: String?, // MIME type @ColumnInfo(name = "size") val size: Long?, // Size in bytes @ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp - @ColumnInfo(name = "url") val url: String, - @ColumnInfo(name = "contentUri") val contentUri: String?, - @ColumnInfo(name = "progress") val progress: Int, + @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) + @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location + @ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded ) { - constructor(name: String?, type: String?, size: Long?, expires: Long?, url: String) : + constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : this(name, type, size, expires, url, null, PROGRESS_NONE) } diff --git a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt index 0459ece..079d9a6 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt @@ -45,23 +45,24 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam if (!response.isSuccessful || response.body == null) { throw Exception("Attachment download failed: ${response.code}") } - val name = attachment.name ?: "attachment.bin" - val mimeType = attachment.type ?: "application/octet-stream" + val name = attachment.name val size = attachment.size ?: 0 val resolver = applicationContext.contentResolver val details = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + if (attachment.type != null) { + put(MediaStore.MediaColumns.MIME_TYPE, attachment.type) + } put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) } val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) ?: throw Exception("Cannot get content URI") Log.d(TAG, "Starting download to content URI: $uri") + var bytesCopied: Long = 0 val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream") out.use { fileOut -> val fileIn = response.body!!.byteStream() - var bytesCopied: Long = 0 val buffer = ByteArray(8 * 1024) var bytes = fileIn.read(buffer) var lastProgress = 0L @@ -80,7 +81,7 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam } } Log.d(TAG, "Attachment download: successful response, proceeding with download") - val newAttachment = attachment.copy(contentUri = uri.toString(), progress = PROGRESS_DONE) + val newAttachment = attachment.copy(contentUri = uri.toString(), size = bytesCopied, progress = PROGRESS_DONE) val newNotification = notification.copy(attachment = newAttachment) repository.updateNotification(newNotification) notifier.update(subscription, newNotification) 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 7166103..9a63456 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -1,12 +1,10 @@ package io.heckel.ntfy.ui import android.app.DownloadManager -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent +import android.content.* import android.graphics.BitmapFactory import android.net.Uri +import android.provider.OpenableColumns import android.util.Log import android.view.LayoutInflater import android.view.View @@ -20,11 +18,14 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.workDataOf import io.heckel.ntfy.R +import io.heckel.ntfy.data.Attachment import io.heckel.ntfy.data.Notification +import io.heckel.ntfy.data.PROGRESS_DONE +import io.heckel.ntfy.data.PROGRESS_NONE import io.heckel.ntfy.msg.AttachmentDownloadWorker -import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.util.* import java.util.* +import kotlin.math.exp class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : @@ -59,11 +60,13 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL 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 newImageView: View = itemView.findViewById(R.id.detail_item_new_dot) + 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 imageView: ImageView = itemView.findViewById(R.id.detail_item_image) - private val attachmentView: TextView = itemView.findViewById(R.id.detail_item_attachment_text) private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button) + private val attachmentImageView: ImageView = itemView.findViewById(R.id.detail_item_attachment_image) + private val attachmentBoxView: View = itemView.findViewById(R.id.detail_item_attachment_box) + private val attachmentIconView: ImageView = itemView.findViewById(R.id.detail_item_attachment_icon) + private val attachmentInfoView: TextView = itemView.findViewById(R.id.detail_item_attachment_info) fun bind(notification: Notification) { this.notification = notification @@ -73,7 +76,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL dateView.text = Date(notification.timestamp * 1000).toString() messageView.text = formatMessage(notification) - newImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE + newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE itemView.setOnClickListener { onClick(notification) } itemView.setOnLongClickListener { onLongClick(notification); true } if (notification.title != "") { @@ -91,6 +94,11 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL if (selected.contains(notification.id)) { itemView.setBackgroundResource(R.color.primarySelectedRowColor); } + renderPriority(context, notification) + maybeRenderAttachment(context, notification) + } + + private fun renderPriority(context: Context, notification: Notification) { when (notification.priority) { 1 -> { priorityImageView.visibility = View.VISIBLE @@ -112,66 +120,170 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp)) } } - val contentUri = notification.attachment?.contentUri - val fileExists = if (contentUri != null) fileExists(context, contentUri) else false - if (contentUri != null && fileExists && supportedImage(notification.attachment.type)) { - try { - val resolver = context.applicationContext.contentResolver - val bitmapStream = resolver.openInputStream(Uri.parse(contentUri)) - val bitmap = BitmapFactory.decodeStream(bitmapStream) - imageView.setImageBitmap(bitmap) - imageView.visibility = View.VISIBLE - } catch (_: Exception) { - imageView.visibility = View.GONE - } - } else { - imageView.visibility = View.GONE - } - if (notification.attachment != null) { - attachmentView.text = formatAttachmentInfo(notification, fileExists) - attachmentView.visibility = View.VISIBLE - menuButton.visibility = View.VISIBLE - menuButton.setOnClickListener { menuView -> - val popup = PopupMenu(context, menuView) - popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) + } - val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) - val openItem = popup.menu.findItem(R.id.detail_item_menu_open) - val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse) - val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) - if (contentUri != null && fileExists) { - openItem.setOnMenuItemClickListener { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(contentUri))) // FIXME try/catch - true - } - browseItem.setOnMenuItemClickListener { - context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) - true - } - copyUrlItem.setOnMenuItemClickListener { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("attachment url", notification.attachment.url) - clipboard.setPrimaryClip(clip) - Toast - .makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) - .show() - true - } - downloadItem.isVisible = false - } else { - openItem.isVisible = false - browseItem.isVisible = false - downloadItem.setOnMenuItemClickListener { - scheduleAttachmentDownload(context, notification) - true - } - } - - popup.show() - } - } else { - attachmentView.visibility = View.GONE + private fun maybeRenderAttachment(context: Context, notification: Notification) { + if (notification.attachment == null) { menuButton.visibility = View.GONE + attachmentImageView.visibility = View.GONE + attachmentBoxView.visibility = View.GONE + return + } + val attachment = notification.attachment + val exists = if (attachment.contentUri != null) fileExists(context, attachment.contentUri) else false + maybeRenderAttachmentImage(context, attachment, exists) + renderAttachmentBox(context, notification, attachment, exists) + } + + private fun renderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, exists: Boolean) { + attachmentInfoView.text = formatAttachmentDetails(context, attachment, exists) + attachmentIconView.setImageResource(if (attachment.type?.startsWith("image/") == true) { + R.drawable.ic_file_image_gray_24dp + } else if (attachment.type?.startsWith("video/") == true) { + R.drawable.ic_file_video_gray_24dp + } else if (attachment.type?.startsWith("audio/") == true) { + R.drawable.ic_file_audio_gray_24dp + } else { + R.drawable.ic_file_document_gray_24dp + }) + val menuButtonPopupMenu = createAttachmentPopup(context, menuButton, notification, attachment, exists) // Heavy lifting not during on-click + if (menuButtonPopupMenu != null) { + menuButton.setOnClickListener { menuButtonPopupMenu.show() } + menuButton.visibility = View.VISIBLE + } else { + menuButton.visibility = View.GONE + } + val attachmentBoxPopupMenu = createAttachmentPopup(context, attachmentBoxView, notification, attachment, exists) // Heavy lifting not during on-click + if (attachmentBoxPopupMenu != null) { + attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() } + } else { + attachmentBoxView.setOnClickListener { + Toast + .makeText(context, context.getString(R.string.detail_item_cannot_download), Toast.LENGTH_LONG) + .show() + } + } + attachmentBoxView.visibility = View.VISIBLE + } + + private fun createAttachmentPopup(context: Context, anchor: View?, notification: Notification, attachment: Attachment, exists: Boolean): PopupMenu? { + val popup = PopupMenu(context, anchor) + popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) + val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) + val openItem = popup.menu.findItem(R.id.detail_item_menu_open) + val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse) + val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) + val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 + if (attachment.contentUri != null) { + openItem.setOnMenuItemClickListener { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(attachment.contentUri))) + } catch (e: ActivityNotFoundException) { + Toast + .makeText(context, context.getString(R.string.detail_item_cannot_open), Toast.LENGTH_LONG) + .show() + } catch (_: Exception) { + // URI parse exception and others; we don't care! + } + true + } + } + browseItem.setOnMenuItemClickListener { + context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) + true + } + copyUrlItem.setOnMenuItemClickListener { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("attachment url", attachment.url) + clipboard.setPrimaryClip(clip) + Toast + .makeText(context, context.getString(R.string.detail_item_menu_copy_url_copied), Toast.LENGTH_LONG) + .show() + true + } + downloadItem.setOnMenuItemClickListener { + scheduleAttachmentDownload(context, notification) + true + } + openItem.isVisible = exists + browseItem.isVisible = exists + downloadItem.isVisible = !exists && !expired + copyUrlItem.isVisible = !expired + val noOptions = !openItem.isVisible && !browseItem.isVisible && !downloadItem.isVisible && !copyUrlItem.isVisible + if (noOptions) { + return null + } + return popup + } + + private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { + val name = queryAttachmentFilename(context, attachment) + val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE + val downloading = !exists && attachment.progress in 0..99 + val deleted = !exists && attachment.progress == PROGRESS_DONE + val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 + val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000 + val infos = mutableListOf() + if (attachment.size != null) { + infos.add(formatBytes(attachment.size)) + } + if (notYetDownloaded) { + if (expired) { + infos.add("not downloaded, link expired") + } else if (expires) { + infos.add("not downloaded, link expires ${formatDateShort(attachment.expires!!)}") + } else { + infos.add("not downloaded") + } + } else if (downloading) { + infos.add("${attachment.progress}% downloaded") + } else if (deleted) { + if (expired) { + infos.add("deleted, link expired") + } else if (expires) { + infos.add("deleted, link expires ${formatDateShort(attachment.expires!!)}") + } else { + infos.add("deleted") + } + } + return if (infos.size > 0) { + "$name\n${infos.joinToString(", ")}" + } else { + name + } + } + + private fun queryAttachmentFilename(context: Context, attachment: Attachment): String { + if (attachment.contentUri == null) { + return attachment.name + } + try { + val resolver = context.applicationContext.contentResolver + val cursor = resolver.query(Uri.parse(attachment.contentUri), null, null, null, null) ?: return attachment.name + return cursor.use { c -> + val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + c.moveToFirst() + c.getString(nameIndex) + } + } catch (_: Exception) { + return attachment.name + } + } + + private fun maybeRenderAttachmentImage(context: Context, att: Attachment, exists: Boolean) { + val fileIsImage = att.contentUri != null && exists && supportedImage(att.type) + if (!fileIsImage) { + attachmentImageView.visibility = View.GONE + return + } + try { + val resolver = context.applicationContext.contentResolver + val bitmapStream = resolver.openInputStream(Uri.parse(att.contentUri)) + val bitmap = BitmapFactory.decodeStream(bitmapStream) + attachmentImageView.setImageBitmap(bitmap) + attachmentImageView.visibility = View.VISIBLE + } catch (_: Exception) { + attachmentImageView.visibility = View.GONE } } 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 dc19ec3..e9154e4 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -109,24 +109,6 @@ fun formatTitle(notification: Notification): String { } } -// FIXME duplicate code -fun formatAttachmentInfo(notification: Notification, fileExists: Boolean): String { - if (notification.attachment == null) return "" - val att = notification.attachment - val infos = mutableListOf() - if (att.name != null) infos.add(att.name) - if (att.size != null) infos.add(formatBytes(att.size)) - //if (att.expires != null && att.expires != 0L) infos.add(formatDateShort(att.expires)) - if (att.progress in 0..99) infos.add("${att.progress}%") - if (!fileExists) { - if (att.progress == PROGRESS_NONE) infos.add("not downloaded") - else infos.add("deleted") - } - if (infos.size == 0) return "" - if (att.progress < 100) return "Downloading ${infos.joinToString(", ")}" - return "\uD83D\uDCC4 " + infos.joinToString(", ") -} - // Checks in the most horrible way if a content URI exists; I couldn't find a better way fun fileExists(context: Context, uri: String): Boolean { val resolver = context.applicationContext.contentResolver @@ -175,7 +157,7 @@ fun formatBytes(bytes: Long): String { i -= 10 } value *= java.lang.Long.signum(bytes).toLong() - return java.lang.String.format("%.1f %ciB", value / 1024.0, ci.current()) + return java.lang.String.format("%.1f %cB", value / 1024.0, ci.current()) } fun supportedImage(mimeType: String?): Boolean { diff --git a/app/src/main/res/drawable/ic_file_audio_gray_24dp.xml b/app/src/main/res/drawable/ic_file_audio_gray_24dp.xml new file mode 100644 index 0000000..7d36262 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_audio_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_document_gray_24dp.xml b/app/src/main/res/drawable/ic_file_document_gray_24dp.xml new file mode 100644 index 0000000..92fe4e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_document_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_image_gray_24dp.xml b/app/src/main/res/drawable/ic_file_image_gray_24dp.xml new file mode 100644 index 0000000..4ed0a1c --- /dev/null +++ b/app/src/main/res/drawable/ic_file_image_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_video_gray_24dp.xml b/app/src/main/res/drawable/ic_file_video_gray_24dp.xml new file mode 100644 index 0000000..9486dfe --- /dev/null +++ b/app/src/main/res/drawable/ic_file_video_gray_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/fragment_detail_item.xml index bcb41e2..a463ae9 100644 --- a/app/src/main/res/layout/fragment_detail_item.xml +++ b/app/src/main/res/layout/fragment_detail_item.xml @@ -48,7 +48,7 @@ app:layout_constraintTop_toBottomOf="@id/detail_item_title_text" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp" - app:layout_constraintBottom_toTopOf="@id/detail_item_image"/> + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image"/> - + app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible" + android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/> + app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image" + app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_box" + app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp" + android:layout_marginBottom="3dp"/> + + + + + app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_box"/> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16c7cb1..d144fef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,6 +114,8 @@ Download file Copy URL Copied URL to clipboard + Cannot open or download attachment. Link expired and no local file found. + Cannot open attachment: File may have been deleted, or there is no app to open the file. Notifications enabled 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 fcf60de..c6ce362 100644 --- a/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt +++ b/app/src/play/java/io/heckel/ntfy/firebase/FirebaseService.kt @@ -59,7 +59,7 @@ class FirebaseService : FirebaseMessagingService() { val priority = data["priority"]?.toIntOrNull() val tags = data["tags"] val click = data["click"] - val attachmentName = data["attachment_name"] + val attachmentName = data["attachment_name"] ?: "attachment.bin" val attachmentType = data["attachment_type"] val attachmentSize = data["attachment_size"]?.toLongOrNull() val attachmentExpires = data["attachment_expires"]?.toLongOrNull() diff --git a/assets/audio_file_black_24dp.svg b/assets/audio_file_black_24dp.svg new file mode 100644 index 0000000..a368149 --- /dev/null +++ b/assets/audio_file_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/description_black_24dp.svg b/assets/description_black_24dp.svg new file mode 100644 index 0000000..8b444aa --- /dev/null +++ b/assets/description_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/image_black_24dp.svg b/assets/image_black_24dp.svg new file mode 100644 index 0000000..4d5f44c --- /dev/null +++ b/assets/image_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/video_file_black_24dp.svg b/assets/video_file_black_24dp.svg new file mode 100644 index 0000000..f78591e --- /dev/null +++ b/assets/video_file_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file