From a12eb78224dc50c55689e9a14ef1997c3435d89b Mon Sep 17 00:00:00 2001 From: Thore Goebel Date: Fri, 16 Jun 2023 15:57:47 +0200 Subject: [PATCH 1/3] 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. --- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 506 +---------------- .../heckel/ntfy/ui/detail/DetailViewHolder.kt | 512 ++++++++++++++++++ 2 files changed, 523 insertions(+), 495 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt 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 0b568038..e55d6fd8 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -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(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(TopicDiffCallback) { val selected = mutableSetOf() // 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, 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() - 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() { 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." } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt new file mode 100644 index 00000000..633e451d --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt @@ -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, + 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() + 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." + } + +} From 3b76564be6752a1566f52ef56eb5c3b6dd598bfc Mon Sep 17 00:00:00 2001 From: Thore Goebel Date: Fri, 16 Jun 2023 16:02:44 +0200 Subject: [PATCH 2/3] refactor: Move DetailAdapter and DetailViewModel into detail package --- app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt | 3 +++ .../main/java/io/heckel/ntfy/ui/{ => detail}/DetailAdapter.kt | 3 +-- .../java/io/heckel/ntfy/ui/{ => detail}/DetailViewModel.kt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) rename app/src/main/java/io/heckel/ntfy/ui/{ => detail}/DetailAdapter.kt (96%) rename app/src/main/java/io/heckel/ntfy/ui/{ => detail}/DetailViewModel.kt (97%) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 851f43ac..f19d87bf 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -35,6 +35,9 @@ import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberServiceManager +import io.heckel.ntfy.ui.detail.DetailAdapter +import io.heckel.ntfy.ui.detail.DetailViewModel +import io.heckel.ntfy.ui.detail.DetailViewModelFactory import io.heckel.ntfy.util.* import kotlinx.coroutines.* import java.util.* diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt similarity index 96% rename from app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt rename to app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt index e55d6fd8..b2041b8c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt @@ -1,4 +1,4 @@ -package io.heckel.ntfy.ui +package io.heckel.ntfy.ui.detail import android.app.Activity import android.view.LayoutInflater @@ -8,7 +8,6 @@ import androidx.recyclerview.widget.ListAdapter import io.heckel.ntfy.R import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository -import io.heckel.ntfy.ui.detail.DetailViewHolder import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewModel.kt similarity index 97% rename from app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt rename to app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewModel.kt index 8606a64a..85e93fc2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewModel.kt @@ -1,4 +1,4 @@ -package io.heckel.ntfy.ui +package io.heckel.ntfy.ui.detail import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel From c6ca0a98858ef91c57c95b7fe282e9c3020674e2 Mon Sep 17 00:00:00 2001 From: Thore Goebel Date: Fri, 16 Jun 2023 19:38:59 +0200 Subject: [PATCH 3/3] Add divider between read and unread notifications --- .../main/java/io/heckel/ntfy/db/Database.kt | 4 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 39 ++++---- .../io/heckel/ntfy/ui/detail/DetailAdapter.kt | 90 +++++++++++++------ .../io/heckel/ntfy/ui/detail/DetailItem.kt | 15 ++++ .../ntfy/ui/detail/DetailItemViewHolder.kt | 9 ++ ...older.kt => NotificationItemViewHolder.kt} | 31 +++---- .../ui/detail/UnreadDividerItemViewHolder.kt | 16 ++++ ..._item.xml => item_notification_detail.xml} | 0 .../main/res/layout/item_unread_divider.xml | 29 ++++++ app/src/main/res/values-night/styles.xml | 4 + app/src/main/res/values/styles.xml | 4 + 11 files changed, 183 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt create mode 100644 app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt rename app/src/main/java/io/heckel/ntfy/ui/detail/{DetailViewHolder.kt => NotificationItemViewHolder.kt} (97%) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt rename app/src/main/res/layout/{fragment_detail_item.xml => item_notification_detail.xml} (100%) create mode 100644 app/src/main/res/layout/item_unread_divider.xml diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 5f397969..856f1127 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -108,7 +108,9 @@ data class Notification( @ColumnInfo(name = "actions") val actions: List?, @Embedded(prefix = "attachment_") val attachment: Attachment?, @ColumnInfo(name = "deleted") val deleted: Boolean, -) +) { + val isUnread: Boolean get() = notificationId != 0 +} @Entity data class Attachment( diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index f19d87bf..a6dcef8e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -31,13 +31,10 @@ import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.firebase.FirebaseMessenger -import io.heckel.ntfy.util.Log import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberServiceManager -import io.heckel.ntfy.ui.detail.DetailAdapter -import io.heckel.ntfy.ui.detail.DetailViewModel -import io.heckel.ntfy.ui.detail.DetailViewModelFactory +import io.heckel.ntfy.ui.detail.* import io.heckel.ntfy.util.* import kotlinx.coroutines.* import java.util.* @@ -197,17 +194,15 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra // Update main list based on viewModel (& its datasource/livedata) val noEntriesText: View = findViewById(R.id.detail_no_notifications) - val onNotificationClick = { n: Notification -> onNotificationClick(n) } - val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } - adapter = DetailAdapter(this, lifecycleScope, repository, onNotificationClick, onNotificationLongClick) + adapter = DetailAdapter(this, lifecycleScope, repository, this::onNotificationItemClick, this::onNotificationItemLongClick) mainList = findViewById(R.id.detail_notification_list) mainList.adapter = adapter viewModel.list(subscriptionId).observe(this) { it?.let { // Show list view - adapter.submitList(it as MutableList) + adapter.submitNotifications(it as MutableList) if (it.isEmpty()) { mainListContainer.visibility = View.GONE noEntriesText.visibility = View.VISIBLE @@ -226,8 +221,19 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { return false } + override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + if (item is UnreadDividerItem) { + return 0 // disallow swiping the unread divider + } + return super.getSwipeDirs(recyclerView, viewHolder) + } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { - val notification = adapter.get(viewHolder.absoluteAdapterPosition) + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + if (item !is NotificationItem) { + return + } + val notification = item.notification lifecycleScope.launch(Dispatchers.IO) { repository.markAsDeleted(notification.id) } @@ -627,7 +633,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra dialog.show() } - private fun onNotificationClick(notification: Notification) { + private fun onNotificationItemClick(notification: Notification) { if (actionMode != null) { handleActionModeClick(notification) } else if (notification.click != "") { @@ -652,7 +658,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra } } - private fun onNotificationLongClick(notification: Notification) { + private fun onNotificationItemLongClick(notification: Notification) { if (actionMode == null) { beginActionMode(notification) } @@ -660,10 +666,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private fun handleActionModeClick(notification: Notification) { adapter.toggleSelection(notification.id) - if (adapter.selected.size == 0) { + if (adapter.selectedNotificationIds.size == 0) { finishActionMode() } else { - actionMode!!.title = adapter.selected.size.toString() + actionMode!!.title = adapter.selectedNotificationIds.size.toString() } } @@ -698,7 +704,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra Log.d(TAG, "Copying multiple notifications to clipboard") lifecycleScope.launch(Dispatchers.IO) { - val content = adapter.selected.joinToString("\n\n") { notificationId -> + val content = adapter.selectedNotificationIds.joinToString("\n\n") { notificationId -> val notification = repository.getNotification(notificationId) notification?.let { decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString() @@ -723,7 +729,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra val dialog = builder .setMessage(R.string.detail_action_mode_delete_dialog_message) .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> - adapter.selected.map { notificationId -> viewModel.markAsDeleted(notificationId) } + adapter.selectedNotificationIds.map { notificationId -> viewModel.markAsDeleted(notificationId) } finishActionMode() } .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> @@ -759,8 +765,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra private fun endActionModeAndRedraw() { actionMode = null - adapter.selected.clear() - adapter.notifyItemRangeChanged(0, adapter.currentList.size) + adapter.clearSelection() // Fade status bar color val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this)) diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt index b2041b8c..dd58f7d5 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailAdapter.kt @@ -5,7 +5,6 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import io.heckel.ntfy.R import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import kotlinx.coroutines.CoroutineScope @@ -17,45 +16,86 @@ class DetailAdapter( private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit -) : ListAdapter(TopicDiffCallback) { - val selected = mutableSetOf() // Notification IDs +) : ListAdapter(TopicDiffCallback) { - /* Creates and inflates view and return TopicViewHolder. */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailItemViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + 0 -> { + val itemView = inflater.inflate(NotificationItemViewHolder.LAYOUT, parent, false) + NotificationItemViewHolder(activity, lifecycleScope, repository, onClick, onLongClick, itemView) + } + 1 -> { + val itemView = inflater.inflate(UnreadDividerItemViewHolder.LAYOUT, parent, false) + UnreadDividerItemViewHolder(itemView) + } + else -> throw IllegalStateException("Unknown viewType $viewType in DetailAdapter") + } } - /* Gets current topic and uses it to bind view. */ - override fun onBindViewHolder(holder: DetailViewHolder, position: Int) { + override fun onBindViewHolder(holder: DetailItemViewHolder, position: Int) { holder.bind(getItem(position)) } - fun get(position: Int): Notification { - return getItem(position) + // original method in ListAdapter is protected + public override fun getItem(position: Int): DetailItem = super.getItem(position) + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is NotificationItem -> 0 + is UnreadDividerItem -> 1 + } } - fun toggleSelection(notificationId: String) { - if (selected.contains(notificationId)) { - selected.remove(notificationId) - } else { - selected.add(notificationId) + /* Take a list of notifications, insert the unread divider if necessary, + and call submitList for the ListAdapter to do its diff magic */ + fun submitNotifications(newList: List) { + val selectedLocal = selectedNotificationIds + val detailList: MutableList = newList.map { notification -> + NotificationItem(notification, selectedLocal.contains(notification.id)) + }.toMutableList() + + val lastUnreadIndex = newList.indexOfLast { notification -> notification.isUnread } + if (lastUnreadIndex != -1) { + detailList.add(lastUnreadIndex + 1, UnreadDividerItem) } + submitList(detailList.toList()) + } - if (selected.size != 0) { - val listIds = currentList.map { notification -> notification.id } - val notificationPosition = listIds.indexOf(notificationId) - notifyItemChanged(notificationPosition) + val selectedNotificationIds + get() = currentList + .filterIsInstance() + .filter { it.isSelected } + .map { it.notification.id } + + fun clearSelection() { + currentList.forEachIndexed { index, detailItem -> + if (detailItem is NotificationItem && detailItem.isSelected) { + detailItem.isSelected = false + notifyItemChanged(index) + } + } + } + + fun toggleSelection(notificationId: String) { + currentList.forEachIndexed { index, detailItem -> + if (detailItem is NotificationItem && detailItem.notification.id == notificationId) { + detailItem.isSelected = !detailItem.isSelected + notifyItemChanged(index) + } } } - object TopicDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { - return oldItem.id == newItem.id + object TopicDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DetailItem, newItem: DetailItem): Boolean { + return if (oldItem is NotificationItem && newItem is NotificationItem) { + oldItem.notification.id == newItem.notification.id + } else { + oldItem is UnreadDividerItem && newItem is UnreadDividerItem + } } - override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean { + override fun areContentsTheSame(oldItem: DetailItem, newItem: DetailItem): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt new file mode 100644 index 00000000..bd8b4067 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItem.kt @@ -0,0 +1,15 @@ +package io.heckel.ntfy.ui.detail + +import io.heckel.ntfy.db.Notification + + +sealed class DetailItem + + +data class NotificationItem( + val notification: Notification, + var isSelected: Boolean, +) : DetailItem() + + +object UnreadDividerItem : DetailItem() diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt new file mode 100644 index 00000000..e0147cda --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/DetailItemViewHolder.kt @@ -0,0 +1,9 @@ +package io.heckel.ntfy.ui.detail + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + + +abstract class DetailItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + abstract fun bind(item: DetailItem) +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt similarity index 97% rename from app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt rename to app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt index 633e451d..a0791c7b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/detail/DetailViewHolder.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/NotificationItemViewHolder.kt @@ -19,7 +19,6 @@ 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 @@ -36,17 +35,15 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ -class DetailViewHolder( +class NotificationItemViewHolder( private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, - itemView: View, - private val selected: Set, val onClick: (Notification) -> Unit, - val onLongClick: (Notification) -> Unit -) : RecyclerView.ViewHolder(itemView) { - private var notification: Notification? = null + val onLongClick: (Notification) -> Unit, + itemView: View +) : DetailItemViewHolder(itemView) { + 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) @@ -64,8 +61,11 @@ class DetailViewHolder( 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 + override fun bind(item: DetailItem) { + if (item !is NotificationItem) { + throw IllegalStateException("Wrong DetailItemType: $item") + } + val notification = item.notification val context = itemView.context val unmatchedTags = unmatchedTags(splitTags(notification.tags)) @@ -83,7 +83,7 @@ class DetailViewHolder( messageView.setOnLongClickListener { onLongClick(notification); true } - newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE + newDotImageView.visibility = if (notification.isUnread) View.VISIBLE else View.GONE cardView.setOnClickListener { onClick(notification) } cardView.setOnLongClickListener { onLongClick(notification); true } if (notification.title != "") { @@ -94,11 +94,12 @@ class DetailViewHolder( } if (unmatchedTags.isNotEmpty()) { tagsView.visibility = View.VISIBLE - tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", ")) + tagsView.text = + context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", ")) } else { tagsView.visibility = View.GONE } - if (selected.contains(notification.id)) { + if (item.isSelected) { cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context)) } else { cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) @@ -504,9 +505,9 @@ class DetailViewHolder( } companion object { - const val TAG = "DetailViewHolder" + const val TAG = "NotificationItemViewHolder" + const val LAYOUT = R.layout.item_notification_detail 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." } - } diff --git a/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt b/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt new file mode 100644 index 00000000..44edbcb2 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/detail/UnreadDividerItemViewHolder.kt @@ -0,0 +1,16 @@ +package io.heckel.ntfy.ui.detail + +import android.view.View +import io.heckel.ntfy.R + + +class UnreadDividerItemViewHolder(itemView: View) : DetailItemViewHolder(itemView) { + + override fun bind(item: DetailItem) { + // nothing to do + } + + companion object { + const val LAYOUT = R.layout.item_unread_divider + } +} diff --git a/app/src/main/res/layout/fragment_detail_item.xml b/app/src/main/res/layout/item_notification_detail.xml similarity index 100% rename from app/src/main/res/layout/fragment_detail_item.xml rename to app/src/main/res/layout/item_notification_detail.xml diff --git a/app/src/main/res/layout/item_unread_divider.xml b/app/src/main/res/layout/item_unread_divider.xml new file mode 100644 index 00000000..c24a067a --- /dev/null +++ b/app/src/main/res/layout/item_unread_divider.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index a71491b4..3999d744 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -38,4 +38,8 @@ + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4f0902d8..1f9a655c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -30,4 +30,8 @@ rounded 5dp + +