Add cancel to downloads

This commit is contained in:
Philipp Heckel 2022-01-11 18:21:30 -05:00
parent 1cf781b27b
commit 40d8d20cc5
7 changed files with 86 additions and 40 deletions

View file

@ -0,0 +1,39 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.util.Log
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
/**
* Download attachment in the background via WorkManager
*
* The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice apparently.
*/
class DownloadManager {
companion object {
private const val TAG = "NtfyDownloadManager"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
fun enqueue(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG,"Enqueuing work to download attachment for notification $id, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setInputData(workDataOf("id" to id))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName)
}
}
}

View file

@ -17,12 +17,13 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.util.queryFilename import io.heckel.ntfy.util.queryFilename
import kotlinx.coroutines.delay
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { class DownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request .callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
@ -83,6 +84,14 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam
var lastProgress = 0L var lastProgress = 0L
while (bytes >= 0) { while (bytes >= 0) {
if (System.currentTimeMillis() - lastProgress > 500) { if (System.currentTimeMillis() - lastProgress > 500) {
if (isStopped) {
Log.d(TAG, "Attachment download was canceled")
val newAttachment = attachment.copy(progress = PROGRESS_NONE)
val newNotification = notification.copy(attachment = newAttachment)
notifier.update(subscription, newNotification)
repository.updateNotification(newNotification)
return
}
val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE val progress = if (size > 0) (bytesCopied.toFloat()/size.toFloat()*100).toInt() else PROGRESS_INDETERMINATE
val newAttachment = attachment.copy(progress = progress) val newAttachment = attachment.copy(progress = progress)
val newNotification = notification.copy(attachment = newAttachment) val newNotification = notification.copy(attachment = newAttachment)

View file

@ -44,10 +44,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
} }
} }
if (download) { if (download) {
// Download attachment in the background via WorkManager DownloadManager.enqueue(context, notification.id)
// The indirection via WorkManager is required since this code may be executed
// in a doze state and Internet may not be available. It's also best practice apparently.
scheduleAttachmentDownload(notification)
} }
} }
@ -85,15 +82,6 @@ class NotificationDispatcher(val context: Context, val repository: Repository) {
return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000) return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
} }
private fun scheduleAttachmentDownload(notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf("id" to notification.id))
.build()
workManager.enqueue(workRequest)
}
companion object { companion object {
private const val TAG = "NtfyNotifDispatch" private const val TAG = "NtfyNotifDispatch"
} }

View file

@ -9,9 +9,6 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
@ -66,6 +63,7 @@ class NotificationService(val context: Context) {
maybeAddOpenAction(builder, notification) maybeAddOpenAction(builder, notification)
maybeAddBrowseAction(builder, notification) maybeAddBrowseAction(builder, notification)
maybeAddDownloadAction(builder, notification) maybeAddDownloadAction(builder, notification)
maybeAddCancelAction(builder, notification)
maybeCreateNotificationChannel(notification.priority) maybeCreateNotificationChannel(notification.priority)
notificationManager.notify(notification.notificationId, builder.build()) notificationManager.notify(notification.notificationId, builder.build())
@ -164,7 +162,7 @@ class NotificationService(val context: Context) {
private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
@ -174,21 +172,31 @@ class NotificationService(val context: Context) {
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java) val intent = Intent(context, DownloadBroadcastReceiver::class.java)
intent.putExtra("action", DOWNLOAD_ACTION_START)
intent.putExtra("id", notification.id) intent.putExtra("id", notification.id)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build())
} }
} }
private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java)
intent.putExtra("action", DOWNLOAD_ACTION_CANCEL)
intent.putExtra("id", notification.id)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build())
}
}
class DownloadBroadcastReceiver : android.content.BroadcastReceiver() { class DownloadBroadcastReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val id = intent.getStringExtra("id") ?: return val id = intent.getStringExtra("id") ?: return
Log.d(TAG, "Enqueuing work to download attachment for notification $id") val action = intent.getStringExtra("action") ?: return
val workManager = WorkManager.getInstance(context) when (action) {
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id)
.setInputData(workDataOf("id" to id)) DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id)
.build() }
workManager.enqueue(workRequest)
} }
} }
@ -254,7 +262,8 @@ class NotificationService(val context: Context) {
companion object { companion object {
private const val TAG = "NtfyNotifService" private const val TAG = "NtfyNotifService"
private const val DOWNLOAD_ATTACHMENT_ACTION = "io.heckel.ntfy.DOWNLOAD_ATTACHMENT" private const val DOWNLOAD_ACTION_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
private const val DOWNLOAD_ACTION_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -2,7 +2,6 @@ package io.heckel.ntfy.ui
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.DownloadManager
import android.content.* import android.content.*
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
@ -25,7 +24,8 @@ import androidx.work.workDataOf
import com.stfalcon.imageviewer.StfalconImageViewer import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.msg.AttachmentDownloadWorker import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadWorker
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import java.util.* import java.util.*
@ -182,10 +182,12 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi
val popup = PopupMenu(context, anchor) val popup = PopupMenu(context, anchor)
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download)
val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel)
val openItem = popup.menu.findItem(R.id.detail_item_menu_open) val openItem = popup.menu.findItem(R.id.detail_item_menu_open)
val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse) val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse)
val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
val inProgress = attachment.progress in 0..99
if (attachment.contentUri != null) { if (attachment.contentUri != null) {
openItem.setOnMenuItemClickListener { openItem.setOnMenuItemClickListener {
try { try {
@ -207,7 +209,7 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi
} }
} }
browseItem.setOnMenuItemClickListener { browseItem.setOnMenuItemClickListener {
val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent) context.startActivity(intent)
true true
@ -227,14 +229,19 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD) ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
scheduleAttachmentDownload(context, notification) DownloadManager.enqueue(context, notification.id)
true
}
cancelItem.setOnMenuItemClickListener {
DownloadManager.cancel(context, notification.id)
true true
} }
openItem.isVisible = exists openItem.isVisible = exists
browseItem.isVisible = exists browseItem.isVisible = exists
downloadItem.isVisible = !exists && !expired downloadItem.isVisible = !exists && !expired && !inProgress
copyUrlItem.isVisible = !expired copyUrlItem.isVisible = !expired
val noOptions = !openItem.isVisible && !browseItem.isVisible && !downloadItem.isVisible && !copyUrlItem.isVisible cancelItem.isVisible = inProgress
val noOptions = !openItem.isVisible && !browseItem.isVisible && !downloadItem.isVisible && !copyUrlItem.isVisible && !cancelItem.isVisible
if (noOptions) { if (noOptions) {
return null return null
} }
@ -304,15 +311,6 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi
attachmentImageView.visibility = View.GONE attachmentImageView.visibility = View.GONE
} }
} }
private fun scheduleAttachmentDownload(context: Context, notification: Notification) {
Log.d(TAG, "Enqueuing work to download attachment")
val workManager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
.setInputData(workDataOf("id" to notification.id))
.build()
workManager.enqueue(workRequest)
}
} }
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() { object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/detail_item_menu_download" android:title="@string/detail_item_menu_download"/> <item android:id="@+id/detail_item_menu_download" android:title="@string/detail_item_menu_download"/>
<item android:id="@+id/detail_item_menu_cancel" android:title="@string/detail_item_menu_cancel"/>
<item android:id="@+id/detail_item_menu_open" android:title="@string/detail_item_menu_open"/> <item android:id="@+id/detail_item_menu_open" android:title="@string/detail_item_menu_open"/>
<item android:id="@+id/detail_item_menu_browse" android:title="@string/detail_item_menu_browse"/> <item android:id="@+id/detail_item_menu_browse" android:title="@string/detail_item_menu_browse"/>
<item android:id="@+id/detail_item_menu_copy_url" android:title="@string/detail_item_menu_copy_url"/> <item android:id="@+id/detail_item_menu_copy_url" android:title="@string/detail_item_menu_copy_url"/>

View file

@ -112,6 +112,7 @@
<string name="detail_item_menu_open">Open file</string> <string name="detail_item_menu_open">Open file</string>
<string name="detail_item_menu_browse">Browse file</string> <string name="detail_item_menu_browse">Browse file</string>
<string name="detail_item_menu_download">Download file</string> <string name="detail_item_menu_download">Download file</string>
<string name="detail_item_menu_cancel">Cancel download</string>
<string name="detail_item_menu_copy_url">Copy URL</string> <string name="detail_item_menu_copy_url">Copy URL</string>
<string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string> <string name="detail_item_menu_copy_url_copied">Copied URL to clipboard</string>
<string name="detail_item_cannot_download">Cannot open or download attachment. Link expired and no local file found.</string> <string name="detail_item_cannot_download">Cannot open or download attachment. Link expired and no local file found.</string>
@ -166,6 +167,7 @@
<string name="notification_popup_action_open">Open</string> <string name="notification_popup_action_open">Open</string>
<string name="notification_popup_action_browse">Browse</string> <string name="notification_popup_action_browse">Browse</string>
<string name="notification_popup_action_download">Download</string> <string name="notification_popup_action_download">Download</string>
<string name="notification_popup_action_cancel">Cancel</string>
<string name="notification_popup_file">%1$s\nFile: %2$s</string> <string name="notification_popup_file">%1$s\nFile: %2$s</string>
<string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string> <string name="notification_popup_file_downloading">Downloading %1$s, %2$d%%\n%3$s</string>
<string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, download successful</string> <string name="notification_popup_file_download_successful">%1$s\nFile: %2$s, download successful</string>