Continued work on attachments

This commit is contained in:
Philipp Heckel 2022-01-10 13:46:31 -05:00
parent e88f87390e
commit 64612dc47f
15 changed files with 273 additions and 118 deletions

View file

@ -59,15 +59,15 @@ data class Notification(
@Entity @Entity
data class Attachment( 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 = "type") val type: String?, // MIME type
@ColumnInfo(name = "size") val size: Long?, // Size in bytes @ColumnInfo(name = "size") val size: Long?, // Size in bytes
@ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp @ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp
@ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
@ColumnInfo(name = "contentUri") val contentUri: String?, @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
@ColumnInfo(name = "progress") val progress: Int, @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) this(name, type, size, expires, url, null, PROGRESS_NONE)
} }

View file

@ -45,23 +45,24 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
throw Exception("Attachment download failed: ${response.code}") throw Exception("Attachment download failed: ${response.code}")
} }
val name = attachment.name ?: "attachment.bin" val name = attachment.name
val mimeType = attachment.type ?: "application/octet-stream"
val size = attachment.size ?: 0 val size = attachment.size ?: 0
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
val details = ContentValues().apply { val details = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name) 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.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1) put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
} }
val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details) val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details)
?: throw Exception("Cannot get content URI") ?: throw Exception("Cannot get content URI")
Log.d(TAG, "Starting download to content URI: $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") val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
out.use { fileOut -> out.use { fileOut ->
val fileIn = response.body!!.byteStream() val fileIn = response.body!!.byteStream()
var bytesCopied: Long = 0
val buffer = ByteArray(8 * 1024) val buffer = ByteArray(8 * 1024)
var bytes = fileIn.read(buffer) var bytes = fileIn.read(buffer)
var lastProgress = 0L 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") 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) val newNotification = notification.copy(attachment = newAttachment)
repository.updateNotification(newNotification) repository.updateNotification(newNotification)
notifier.update(subscription, newNotification) notifier.update(subscription, newNotification)

View file

@ -1,12 +1,10 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.app.DownloadManager import android.app.DownloadManager
import android.content.ClipData import android.content.*
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -20,11 +18,14 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Attachment
import io.heckel.ntfy.data.Notification 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.AttachmentDownloadWorker
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import java.util.* import java.util.*
import kotlin.math.exp
class DetailAdapter(private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : 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 dateView: TextView = itemView.findViewById(R.id.detail_item_date_text)
private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_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 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 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 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) { fun bind(notification: Notification) {
this.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() dateView.text = Date(notification.timestamp * 1000).toString()
messageView.text = formatMessage(notification) 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.setOnClickListener { onClick(notification) }
itemView.setOnLongClickListener { onLongClick(notification); true } itemView.setOnLongClickListener { onLongClick(notification); true }
if (notification.title != "") { if (notification.title != "") {
@ -91,6 +94,11 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
if (selected.contains(notification.id)) { if (selected.contains(notification.id)) {
itemView.setBackgroundResource(R.color.primarySelectedRowColor); itemView.setBackgroundResource(R.color.primarySelectedRowColor);
} }
renderPriority(context, notification)
maybeRenderAttachment(context, notification)
}
private fun renderPriority(context: Context, notification: Notification) {
when (notification.priority) { when (notification.priority) {
1 -> { 1 -> {
priorityImageView.visibility = View.VISIBLE 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)) 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)
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 downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val openItem = popup.menu.findItem(R.id.detail_item_menu_open) val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse) val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse)
val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
if (contentUri != null && fileExists) { val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
if (attachment.contentUri != null) {
openItem.setOnMenuItemClickListener { openItem.setOnMenuItemClickListener {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(contentUri))) // FIXME try/catch 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 true
} }
}
browseItem.setOnMenuItemClickListener { browseItem.setOnMenuItemClickListener {
context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS))
true true
} }
copyUrlItem.setOnMenuItemClickListener { copyUrlItem.setOnMenuItemClickListener {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("attachment url", notification.attachment.url) val clip = ClipData.newPlainText("attachment url", attachment.url)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
Toast Toast
.makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) .makeText(context, context.getString(R.string.detail_item_menu_copy_url_copied), Toast.LENGTH_LONG)
.show() .show()
true true
} }
downloadItem.isVisible = false
} else {
openItem.isVisible = false
browseItem.isVisible = false
downloadItem.setOnMenuItemClickListener { downloadItem.setOnMenuItemClickListener {
scheduleAttachmentDownload(context, notification) scheduleAttachmentDownload(context, notification)
true 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
} }
popup.show() 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<String>()
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 { } else {
attachmentView.visibility = View.GONE infos.add("not downloaded")
menuButton.visibility = View.GONE }
} 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
} }
} }

View file

@ -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<String>()
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 // 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 { fun fileExists(context: Context, uri: String): Boolean {
val resolver = context.applicationContext.contentResolver val resolver = context.applicationContext.contentResolver
@ -175,7 +157,7 @@ fun formatBytes(bytes: Long): String {
i -= 10 i -= 10
} }
value *= java.lang.Long.signum(bytes).toLong() 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 { fun supportedImage(mimeType: String?): Boolean {

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM6,20V4h7v5h5v11H6zM16,11h-4v3.88c-0.36,-0.24 -0.79,-0.38 -1.25,-0.38c-1.24,0 -2.25,1.01 -2.25,2.25c0,1.24 1.01,2.25 2.25,2.25S13,17.99 13,16.75V13h3V11z"
android:fillColor="#555555"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8,16h8v2L8,18zM8,12h8v2L8,14zM14,2L6,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM18,20L6,20L6,4h7v5h5v11z"
android:fillColor="#555555"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,5v14L5,19L5,5h14m0,-2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"
android:fillColor="#555555"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14,2H6C4.9,2 4,2.9 4,4v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM6,20V4h7v5h5v11H6zM14,14l2,-1.06v4.12L14,16v1c0,0.55 -0.45,1 -1,1H9c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h4c0.55,0 1,0.45 1,1V14z"
android:fillColor="#555555"/>
</vector>

View file

@ -48,7 +48,7 @@
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text" app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="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"/>
<TextView <TextView
android:text="This is an optional title. It can also be a little longer but not too long." android:text="This is an optional title. It can also be a little longer but not too long."
android:layout_width="0dp" android:layout_width="0dp"
@ -71,25 +71,13 @@
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_image" app:layout_constraintStart_toStartOf="parent" android:id="@+id/detail_item_attachment_image" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text"
android:layout_marginStart="10dp" android:layout_marginEnd="10dp" android:layout_marginStart="10dp" android:layout_marginEnd="10dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp" android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="5dp"
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_text" android:layout_marginBottom="5dp" app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"/> android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/>
<TextView
android:text="📄attachment.jpg, 20.1 KB"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_attachment_text"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:autoLink="web"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_image"
app:layout_constraintBottom_toTopOf="@+id/detail_item_tags_text" android:layout_marginTop="3dp"/>
<TextView <TextView
android:text="Tags: ssh, zfs" android:text="Tags: ssh, zfs"
android:layout_width="0dp" android:layout_width="0dp"
@ -98,13 +86,43 @@
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="10dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="10dp"
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_text" app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image"
app:layout_constraintBottom_toTopOf="@id/detail_item_padding_bottom"/> app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_box"
app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp"
android:layout_marginBottom="3dp"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"
android:id="@+id/detail_item_attachment_box" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginStart="10dp" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toTopOf="@id/detail_item_padding_bottom"
android:visibility="visible" android:layout_marginTop="2dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
android:id="@+id/detail_item_attachment_icon" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@+id/detail_item_attachment_info" android:layout_marginEnd="3dp"
app:layout_constraintBottom_toBottomOf="parent"
/>
<TextView
android:text="attachment.jpg\n58 MB, not downloaded, expires 1/2/2022 10:30 PM"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/detail_item_attachment_info"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:layout_constraintStart_toEndOf="@+id/detail_item_attachment_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/detail_item_attachment_icon"
app:layout_constraintBottom_toBottomOf="@+id/detail_item_attachment_icon"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="10dp" android:id="@+id/detail_item_padding_bottom" android:layout_height="5dp" android:id="@+id/detail_item_padding_bottom"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/detail_item_tags_text" app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_box"/>
app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -114,6 +114,8 @@
<string name="detail_item_menu_download">Download file</string> <string name="detail_item_menu_download">Download file</string>
<string name="detail_item_menu_copy_url">Copy URL</string> <string name="detail_item_menu_copy_url">Copy URL</string>
<string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string> <string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string>
<string name="detail_item_cannot_download">Cannot open or download attachment. Link expired and no local file found.</string>
<string name="detail_item_cannot_open">Cannot open attachment: File may have been deleted, or there is no app to open the file.</string>
<!-- Detail activity: Action bar --> <!-- Detail activity: Action bar -->
<string name="detail_menu_notifications_enabled">Notifications enabled</string> <string name="detail_menu_notifications_enabled">Notifications enabled</string>

View file

@ -59,7 +59,7 @@ class FirebaseService : FirebaseMessagingService() {
val priority = data["priority"]?.toIntOrNull() val priority = data["priority"]?.toIntOrNull()
val tags = data["tags"] val tags = data["tags"]
val click = data["click"] val click = data["click"]
val attachmentName = data["attachment_name"] val attachmentName = data["attachment_name"] ?: "attachment.bin"
val attachmentType = data["attachment_type"] val attachmentType = data["attachment_type"]
val attachmentSize = data["attachment_size"]?.toLongOrNull() val attachmentSize = data["attachment_size"]?.toLongOrNull()
val attachmentExpires = data["attachment_expires"]?.toLongOrNull() val attachmentExpires = data["attachment_expires"]?.toLongOrNull()

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M14,2H6C4.9,2,4,2.9,4,4v16c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V8L14,2z M6,20V4h7v5h5v11H6z M16,11h-4v3.88 c-0.36-0.24-0.79-0.38-1.25-0.38c-1.24,0-2.25,1.01-2.25,2.25c0,1.24,1.01,2.25,2.25,2.25S13,17.99,13,16.75V13h3V11z"/></g></svg>

After

Width:  |  Height:  |  Size: 428 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>

After

Width:  |  Height:  |  Size: 271 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M14,2H6C4.9,2,4,2.9,4,4v16c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V8L14,2z M6,20V4h7v5h5v11H6z M14,14l2-1.06v4.12L14,16v1 c0,0.55-0.45,1-1,1H9c-0.55,0-1-0.45-1-1v-4c0-0.55,0.45-1,1-1h4c0.55,0,1,0.45,1,1V14z"/></g></svg>

After

Width:  |  Height:  |  Size: 411 B