mirror of
https://github.com/binwiederhier/ntfy-android.git
synced 2024-05-17 11:02:36 +12:00
refactor: Move DetailViewHolder out of DetailAdapter
The only real content changes are: 1. The companion object 2. Formatting the two constructors to be multiline instead of one looong line Otherwise it's copy and paste of the DetailViewHolder and the imports that it needs.
This commit is contained in:
parent
c15efff72c
commit
a12eb78224
|
@ -1,45 +1,24 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.constraintlayout.helper.widget.Flow
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.allViews
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.DownloadAttachmentWorker
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadType
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW
|
||||
import io.heckel.ntfy.util.*
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.ui.detail.DetailViewHolder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DetailAdapter(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) :
|
||||
ListAdapter<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
|
||||
|
||||
class DetailAdapter(
|
||||
private val activity: Activity,
|
||||
private val lifecycleScope: CoroutineScope,
|
||||
private val repository: Repository,
|
||||
private val onClick: (Notification) -> Unit,
|
||||
private val onLongClick: (Notification) -> Unit
|
||||
) : ListAdapter<Notification, DetailViewHolder>(TopicDiffCallback) {
|
||||
val selected = mutableSetOf<String>() // Notification IDs
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
|
@ -72,467 +51,6 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope:
|
|||
}
|
||||
}
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class DetailViewHolder(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, itemView: View, private val selected: Set<String>, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
private var notification: Notification? = null
|
||||
private val layout: View = itemView.findViewById(R.id.detail_item_layout)
|
||||
private val cardView: CardView = itemView.findViewById(R.id.detail_item_card)
|
||||
private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image)
|
||||
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 iconView: ImageView = itemView.findViewById(R.id.detail_item_icon)
|
||||
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 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_file_box)
|
||||
private val attachmentIconView: ImageView = itemView.findViewById(R.id.detail_item_attachment_file_icon)
|
||||
private val attachmentInfoView: TextView = itemView.findViewById(R.id.detail_item_attachment_file_info)
|
||||
private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper)
|
||||
private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow)
|
||||
|
||||
fun bind(notification: Notification) {
|
||||
this.notification = notification
|
||||
|
||||
val context = itemView.context
|
||||
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
|
||||
|
||||
dateView.text = formatDateShort(notification.timestamp)
|
||||
messageView.text = maybeAppendActionErrors(formatMessage(notification), notification)
|
||||
messageView.setOnClickListener {
|
||||
// Click & Long-click listeners on the text as well, because "autoLink=web" makes them
|
||||
// clickable, and so we cannot rely on the underlying card to perform the action.
|
||||
// It's weird because "layout" is the ripple-able, but the card is clickable.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/226
|
||||
layout.ripple(lifecycleScope)
|
||||
onClick(notification)
|
||||
}
|
||||
messageView.setOnLongClickListener {
|
||||
onLongClick(notification); true
|
||||
}
|
||||
newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
|
||||
cardView.setOnClickListener { onClick(notification) }
|
||||
cardView.setOnLongClickListener { onLongClick(notification); true }
|
||||
if (notification.title != "") {
|
||||
titleView.visibility = View.VISIBLE
|
||||
titleView.text = formatTitle(notification)
|
||||
} else {
|
||||
titleView.visibility = View.GONE
|
||||
}
|
||||
if (unmatchedTags.isNotEmpty()) {
|
||||
tagsView.visibility = View.VISIBLE
|
||||
tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", "))
|
||||
} else {
|
||||
tagsView.visibility = View.GONE
|
||||
}
|
||||
if (selected.contains(notification.id)) {
|
||||
cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context))
|
||||
} else {
|
||||
cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context))
|
||||
}
|
||||
val attachment = notification.attachment
|
||||
val attachmentFileStat = maybeFileStat(context, attachment?.contentUri)
|
||||
val iconFileStat = maybeFileStat(context, notification.icon?.contentUri)
|
||||
renderPriority(context, notification)
|
||||
resetCardButtons()
|
||||
maybeRenderMenu(context, notification, attachmentFileStat)
|
||||
maybeRenderAttachment(context, notification, attachmentFileStat)
|
||||
maybeRenderIcon(context, notification, iconFileStat)
|
||||
maybeRenderActions(context, notification)
|
||||
}
|
||||
|
||||
private fun renderPriority(context: Context, notification: Notification) {
|
||||
when (notification.priority) {
|
||||
PRIORITY_MIN -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp))
|
||||
}
|
||||
PRIORITY_LOW -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp))
|
||||
}
|
||||
PRIORITY_DEFAULT -> {
|
||||
priorityImageView.visibility = View.GONE
|
||||
}
|
||||
PRIORITY_HIGH -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp))
|
||||
}
|
||||
PRIORITY_MAX -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 && supportedImage(attachment.type) && previewableImage(attachmentFileStat)
|
||||
val bitmap = if (image) attachment.contentUri?.readBitmapFromUriOrNull(context) else null
|
||||
maybeRenderAttachmentImage(context, bitmap)
|
||||
maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, bitmap)
|
||||
}
|
||||
|
||||
private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
|
||||
if (notification.icon == null || !previewableImage(iconStat)) {
|
||||
iconView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
val icon = notification.icon
|
||||
val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty")
|
||||
iconView.setImageBitmap(bitmap)
|
||||
iconView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
iconView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
menuButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderActions(context: Context, notification: Notification) {
|
||||
if (!notification.actions.isNullOrEmpty()) {
|
||||
actionsWrapperView.visibility = View.VISIBLE
|
||||
val actionsCount = Math.min(notification.actions.size, 3) // per documentation, only 3 actions are available
|
||||
for (i in 0 until actionsCount) {
|
||||
val action = notification.actions[i]
|
||||
val label = formatActionLabel(action)
|
||||
val actionButton = createCardButton(context, label) { runAction(context, notification, action) }
|
||||
addButtonToCard(actionButton)
|
||||
}
|
||||
} else {
|
||||
actionsWrapperView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCardButtons() {
|
||||
// clear any previously created dynamic buttons
|
||||
actionsFlow.allViews.forEach { actionsFlow.removeView(it) }
|
||||
actionsWrapperView.removeAllViews()
|
||||
actionsWrapperView.addView(actionsFlow)
|
||||
}
|
||||
|
||||
private fun addButtonToCard(button: View) {
|
||||
actionsWrapperView.addView(button)
|
||||
actionsFlow.addView(button)
|
||||
}
|
||||
|
||||
private fun createCardButton(context: Context, label: String, onClick: () -> Boolean): View {
|
||||
// See https://stackoverflow.com/a/41139179/1440785
|
||||
val button = LayoutInflater.from(context).inflate(R.layout.button_action, null) as MaterialButton
|
||||
button.id = View.generateViewId()
|
||||
button.text = label
|
||||
button.setOnClickListener { onClick() }
|
||||
return button
|
||||
}
|
||||
|
||||
private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
|
||||
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type))
|
||||
val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // 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 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)
|
||||
val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
|
||||
val deleteItem = popup.menu.findItem(R.id.detail_item_menu_delete)
|
||||
val saveFileItem = popup.menu.findItem(R.id.detail_item_menu_save_file)
|
||||
val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
|
||||
val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_copy_contents)
|
||||
val expired = attachment?.expires != null && attachment.expires < System.currentTimeMillis()/1000
|
||||
val inProgress = attachment?.progress in 0..99
|
||||
if (attachment != null) {
|
||||
openItem.setOnMenuItemClickListener { openFile(context, attachment) }
|
||||
saveFileItem.setOnMenuItemClickListener { saveFile(context, attachment) }
|
||||
deleteItem.setOnMenuItemClickListener { deleteFile(context, notification, attachment) }
|
||||
copyUrlItem.setOnMenuItemClickListener { copyUrl(context, attachment) }
|
||||
downloadItem.setOnMenuItemClickListener { downloadFile(context, notification) }
|
||||
cancelItem.setOnMenuItemClickListener { cancelDownload(context, notification) }
|
||||
}
|
||||
if (hasClickLink) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
openItem.isVisible = hasAttachment && attachmentExists
|
||||
downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && attachmentExists
|
||||
saveFileItem.isVisible = hasAttachment && attachmentExists
|
||||
copyUrlItem.isVisible = hasAttachment && !expired
|
||||
cancelItem.isVisible = hasAttachment && inProgress
|
||||
copyContentsItem.isVisible = notification.click != ""
|
||||
val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
|
||||
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
|
||||
&& !copyContentsItem.isVisible
|
||||
if (noOptions) {
|
||||
return null
|
||||
}
|
||||
return popup
|
||||
}
|
||||
|
||||
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)
|
||||
val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
|
||||
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(context.getString(R.string.detail_item_download_info_not_downloaded_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded))
|
||||
}
|
||||
} else if (downloading) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_downloading_x_percent, attachment.progress))
|
||||
} else if (deleted) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted))
|
||||
}
|
||||
} else if (failed) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed))
|
||||
}
|
||||
}
|
||||
return if (infos.size > 0) {
|
||||
"$name\n${infos.joinToString(", ")}"
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderAttachmentImage(context: Context, bitmap: Bitmap?) {
|
||||
if (bitmap == null) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
attachmentImageView.setImageBitmap(bitmap)
|
||||
attachmentImageView.setOnClickListener {
|
||||
val loadImage = { view: ImageView, image: Bitmap -> view.setImageBitmap(image) }
|
||||
StfalconImageViewer.Builder(context, listOf(bitmap), loadImage)
|
||||
.allowZooming(true)
|
||||
.withTransitionFrom(attachmentImageView)
|
||||
.withHiddenStatusBar(false)
|
||||
.show()
|
||||
}
|
||||
attachmentImageView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFile(context: Context, attachment: Attachment): Boolean {
|
||||
if (!canOpenAttachment(attachment)) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_apk), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
Log.d(TAG, "Opening file ${attachment.contentUri}")
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val intent = Intent(Intent.ACTION_VIEW, contentUri)
|
||||
intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_not_found), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveFile(context: Context, attachment: Attachment): Boolean {
|
||||
Log.d(TAG, "Copying file ${attachment.contentUri}")
|
||||
try {
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, attachment.name)
|
||||
if (attachment.type != null) {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1) // While downloading
|
||||
}
|
||||
}
|
||||
val inUri = Uri.parse(attachment.contentUri)
|
||||
val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream")
|
||||
val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
|
||||
FileProvider.getUriForFile(context, DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
|
||||
} else {
|
||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
|
||||
}
|
||||
val outFile = resolver.openOutputStream(outUri) ?: throw Exception("Cannot open output stream")
|
||||
inFile.use { it.copyTo(outFile) }
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
values.clear() // See #116 to avoid "movement" error
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
resolver.update(outUri, values, null, null)
|
||||
}
|
||||
val actualName = fileName(context, outUri.toString(), attachment.name)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_saved_successfully, actualName), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to save file: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_save, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteFile(context: Context, notification: Notification, attachment: Attachment): Boolean {
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val deleted = resolver.delete(contentUri, null, null) > 0
|
||||
if (!deleted) throw Exception("no rows deleted")
|
||||
val newAttachment = attachment.copy(
|
||||
contentUri = null,
|
||||
progress = ATTACHMENT_PROGRESS_DELETED
|
||||
)
|
||||
val newNotification = notification.copy(attachment = newAttachment)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
repository.updateNotification(newNotification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update notification: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_delete, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadFile(context: Context, notification: Notification): Boolean {
|
||||
val requiresPermission = Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED
|
||||
if (requiresPermission) {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
|
||||
return true
|
||||
}
|
||||
DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun cancelDownload(context: Context, notification: Notification): Boolean {
|
||||
DownloadManager.cancel(context, notification.id)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyUrl(context: Context, attachment: Attachment): Boolean {
|
||||
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()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyContents(context: Context, notification: Notification): Boolean {
|
||||
copyToClipboard(context, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runAction(context: Context, notification: Notification, action: Action): Boolean {
|
||||
when (action.action) {
|
||||
ACTION_VIEW -> runViewAction(context, action)
|
||||
else -> runOtherUserAction(context, notification, action)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runViewAction(context: Context, action: Action) {
|
||||
try {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to start activity from URL ${action.url}", e)
|
||||
val message = if (e is ActivityNotFoundException) action.url else e.message
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_url, message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOtherUserAction(context: Context, notification: Notification, action: Action) {
|
||||
val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id)
|
||||
}
|
||||
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>() {
|
||||
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
|
@ -545,7 +63,5 @@ 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."
|
||||
}
|
||||
}
|
||||
|
|
512
app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt
Normal file
512
app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt
Normal file
|
@ -0,0 +1,512 @@
|
|||
package io.heckel.ntfy.ui.detail
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.constraintlayout.helper.widget.Flow
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.allViews
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.DownloadAttachmentWorker
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadType
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.ui.Colors
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class DetailViewHolder(
|
||||
private val activity: Activity,
|
||||
private val lifecycleScope: CoroutineScope,
|
||||
private val repository: Repository,
|
||||
itemView: View,
|
||||
private val selected: Set<String>,
|
||||
val onClick: (Notification) -> Unit,
|
||||
val onLongClick: (Notification) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
private var notification: Notification? = null
|
||||
private val layout: View = itemView.findViewById(R.id.detail_item_layout)
|
||||
private val cardView: CardView = itemView.findViewById(R.id.detail_item_card)
|
||||
private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image)
|
||||
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 iconView: ImageView = itemView.findViewById(R.id.detail_item_icon)
|
||||
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 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_file_box)
|
||||
private val attachmentIconView: ImageView = itemView.findViewById(R.id.detail_item_attachment_file_icon)
|
||||
private val attachmentInfoView: TextView = itemView.findViewById(R.id.detail_item_attachment_file_info)
|
||||
private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper)
|
||||
private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow)
|
||||
|
||||
fun bind(notification: Notification) {
|
||||
this.notification = notification
|
||||
|
||||
val context = itemView.context
|
||||
val unmatchedTags = unmatchedTags(splitTags(notification.tags))
|
||||
|
||||
dateView.text = formatDateShort(notification.timestamp)
|
||||
messageView.text = maybeAppendActionErrors(formatMessage(notification), notification)
|
||||
messageView.setOnClickListener {
|
||||
// Click & Long-click listeners on the text as well, because "autoLink=web" makes them
|
||||
// clickable, and so we cannot rely on the underlying card to perform the action.
|
||||
// It's weird because "layout" is the ripple-able, but the card is clickable.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/226
|
||||
layout.ripple(lifecycleScope)
|
||||
onClick(notification)
|
||||
}
|
||||
messageView.setOnLongClickListener {
|
||||
onLongClick(notification); true
|
||||
}
|
||||
newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
|
||||
cardView.setOnClickListener { onClick(notification) }
|
||||
cardView.setOnLongClickListener { onLongClick(notification); true }
|
||||
if (notification.title != "") {
|
||||
titleView.visibility = View.VISIBLE
|
||||
titleView.text = formatTitle(notification)
|
||||
} else {
|
||||
titleView.visibility = View.GONE
|
||||
}
|
||||
if (unmatchedTags.isNotEmpty()) {
|
||||
tagsView.visibility = View.VISIBLE
|
||||
tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", "))
|
||||
} else {
|
||||
tagsView.visibility = View.GONE
|
||||
}
|
||||
if (selected.contains(notification.id)) {
|
||||
cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context))
|
||||
} else {
|
||||
cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context))
|
||||
}
|
||||
val attachment = notification.attachment
|
||||
val attachmentFileStat = maybeFileStat(context, attachment?.contentUri)
|
||||
val iconFileStat = maybeFileStat(context, notification.icon?.contentUri)
|
||||
renderPriority(context, notification)
|
||||
resetCardButtons()
|
||||
maybeRenderMenu(context, notification, attachmentFileStat)
|
||||
maybeRenderAttachment(context, notification, attachmentFileStat)
|
||||
maybeRenderIcon(context, notification, iconFileStat)
|
||||
maybeRenderActions(context, notification)
|
||||
}
|
||||
|
||||
private fun renderPriority(context: Context, notification: Notification) {
|
||||
when (notification.priority) {
|
||||
PRIORITY_MIN -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp))
|
||||
}
|
||||
PRIORITY_LOW -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp))
|
||||
}
|
||||
PRIORITY_DEFAULT -> {
|
||||
priorityImageView.visibility = View.GONE
|
||||
}
|
||||
PRIORITY_HIGH -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp))
|
||||
}
|
||||
PRIORITY_MAX -> {
|
||||
priorityImageView.visibility = View.VISIBLE
|
||||
priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 && supportedImage(attachment.type) && previewableImage(attachmentFileStat)
|
||||
val bitmap = if (image) attachment.contentUri?.readBitmapFromUriOrNull(context) else null
|
||||
maybeRenderAttachmentImage(context, bitmap)
|
||||
maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, bitmap)
|
||||
}
|
||||
|
||||
private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
|
||||
if (notification.icon == null || !previewableImage(iconStat)) {
|
||||
iconView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
val icon = notification.icon
|
||||
val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty")
|
||||
iconView.setImageBitmap(bitmap)
|
||||
iconView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
iconView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
menuButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderActions(context: Context, notification: Notification) {
|
||||
if (!notification.actions.isNullOrEmpty()) {
|
||||
actionsWrapperView.visibility = View.VISIBLE
|
||||
val actionsCount = Math.min(notification.actions.size, 3) // per documentation, only 3 actions are available
|
||||
for (i in 0 until actionsCount) {
|
||||
val action = notification.actions[i]
|
||||
val label = formatActionLabel(action)
|
||||
val actionButton = createCardButton(context, label) { runAction(context, notification, action) }
|
||||
addButtonToCard(actionButton)
|
||||
}
|
||||
} else {
|
||||
actionsWrapperView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCardButtons() {
|
||||
// clear any previously created dynamic buttons
|
||||
actionsFlow.allViews.forEach { actionsFlow.removeView(it) }
|
||||
actionsWrapperView.removeAllViews()
|
||||
actionsWrapperView.addView(actionsFlow)
|
||||
}
|
||||
|
||||
private fun addButtonToCard(button: View) {
|
||||
actionsWrapperView.addView(button)
|
||||
actionsFlow.addView(button)
|
||||
}
|
||||
|
||||
private fun createCardButton(context: Context, label: String, onClick: () -> Boolean): View {
|
||||
// See https://stackoverflow.com/a/41139179/1440785
|
||||
val button = LayoutInflater.from(context).inflate(R.layout.button_action, null) as MaterialButton
|
||||
button.id = View.generateViewId()
|
||||
button.text = label
|
||||
button.setOnClickListener { onClick() }
|
||||
return button
|
||||
}
|
||||
|
||||
private fun maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
|
||||
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type))
|
||||
val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // 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 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)
|
||||
val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
|
||||
val deleteItem = popup.menu.findItem(R.id.detail_item_menu_delete)
|
||||
val saveFileItem = popup.menu.findItem(R.id.detail_item_menu_save_file)
|
||||
val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
|
||||
val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_copy_contents)
|
||||
val expired = attachment?.expires != null && attachment.expires < System.currentTimeMillis()/1000
|
||||
val inProgress = attachment?.progress in 0..99
|
||||
if (attachment != null) {
|
||||
openItem.setOnMenuItemClickListener { openFile(context, attachment) }
|
||||
saveFileItem.setOnMenuItemClickListener { saveFile(context, attachment) }
|
||||
deleteItem.setOnMenuItemClickListener { deleteFile(context, notification, attachment) }
|
||||
copyUrlItem.setOnMenuItemClickListener { copyUrl(context, attachment) }
|
||||
downloadItem.setOnMenuItemClickListener { downloadFile(context, notification) }
|
||||
cancelItem.setOnMenuItemClickListener { cancelDownload(context, notification) }
|
||||
}
|
||||
if (hasClickLink) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
openItem.isVisible = hasAttachment && attachmentExists
|
||||
downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && attachmentExists
|
||||
saveFileItem.isVisible = hasAttachment && attachmentExists
|
||||
copyUrlItem.isVisible = hasAttachment && !expired
|
||||
cancelItem.isVisible = hasAttachment && inProgress
|
||||
copyContentsItem.isVisible = notification.click != ""
|
||||
val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
|
||||
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
|
||||
&& !copyContentsItem.isVisible
|
||||
if (noOptions) {
|
||||
return null
|
||||
}
|
||||
return popup
|
||||
}
|
||||
|
||||
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)
|
||||
val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
|
||||
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(context.getString(R.string.detail_item_download_info_not_downloaded_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded))
|
||||
}
|
||||
} else if (downloading) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_downloading_x_percent, attachment.progress))
|
||||
} else if (deleted) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted))
|
||||
}
|
||||
} else if (failed) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed))
|
||||
}
|
||||
}
|
||||
return if (infos.size > 0) {
|
||||
"$name\n${infos.joinToString(", ")}"
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderAttachmentImage(context: Context, bitmap: Bitmap?) {
|
||||
if (bitmap == null) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
attachmentImageView.setImageBitmap(bitmap)
|
||||
attachmentImageView.setOnClickListener {
|
||||
val loadImage = { view: ImageView, image: Bitmap -> view.setImageBitmap(image) }
|
||||
StfalconImageViewer.Builder(context, listOf(bitmap), loadImage)
|
||||
.allowZooming(true)
|
||||
.withTransitionFrom(attachmentImageView)
|
||||
.withHiddenStatusBar(false)
|
||||
.show()
|
||||
}
|
||||
attachmentImageView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFile(context: Context, attachment: Attachment): Boolean {
|
||||
if (!canOpenAttachment(attachment)) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_apk), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
Log.d(TAG, "Opening file ${attachment.contentUri}")
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val intent = Intent(Intent.ACTION_VIEW, contentUri)
|
||||
intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_not_found), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveFile(context: Context, attachment: Attachment): Boolean {
|
||||
Log.d(TAG, "Copying file ${attachment.contentUri}")
|
||||
try {
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, attachment.name)
|
||||
if (attachment.type != null) {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1) // While downloading
|
||||
}
|
||||
}
|
||||
val inUri = Uri.parse(attachment.contentUri)
|
||||
val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream")
|
||||
val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
|
||||
FileProvider.getUriForFile(context, DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
|
||||
} else {
|
||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
|
||||
}
|
||||
val outFile = resolver.openOutputStream(outUri) ?: throw Exception("Cannot open output stream")
|
||||
inFile.use { it.copyTo(outFile) }
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
values.clear() // See #116 to avoid "movement" error
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
resolver.update(outUri, values, null, null)
|
||||
}
|
||||
val actualName = fileName(context, outUri.toString(), attachment.name)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_saved_successfully, actualName), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to save file: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_save, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteFile(context: Context, notification: Notification, attachment: Attachment): Boolean {
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val deleted = resolver.delete(contentUri, null, null) > 0
|
||||
if (!deleted) throw Exception("no rows deleted")
|
||||
val newAttachment = attachment.copy(
|
||||
contentUri = null,
|
||||
progress = ATTACHMENT_PROGRESS_DELETED
|
||||
)
|
||||
val newNotification = notification.copy(attachment = newAttachment)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
repository.updateNotification(newNotification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update notification: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_delete, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadFile(context: Context, notification: Notification): Boolean {
|
||||
val requiresPermission = Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED
|
||||
if (requiresPermission) {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
|
||||
return true
|
||||
}
|
||||
DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun cancelDownload(context: Context, notification: Notification): Boolean {
|
||||
DownloadManager.cancel(context, notification.id)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyUrl(context: Context, attachment: Attachment): Boolean {
|
||||
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()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyContents(context: Context, notification: Notification): Boolean {
|
||||
copyToClipboard(context, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runAction(context: Context, notification: Notification, action: Action): Boolean {
|
||||
when (action.action) {
|
||||
NotificationService.ACTION_VIEW -> runViewAction(context, action)
|
||||
else -> runOtherUserAction(context, notification, action)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runViewAction(context: Context, action: Action) {
|
||||
try {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to start activity from URL ${action.url}", e)
|
||||
val message = if (e is ActivityNotFoundException) action.url else e.message
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_url, message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOtherUserAction(context: Context, notification: Notification, action: Action) {
|
||||
val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun previewableImage(fileStat: FileInfo?): Boolean {
|
||||
return if (fileStat != null) fileStat.size <= IMAGE_PREVIEW_MAX_BYTES else false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DetailViewHolder"
|
||||
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."
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in a new issue