diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt new file mode 100644 index 0000000..678002d --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt @@ -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) + } + + } +} diff --git a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt similarity index 91% rename from app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt rename to app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt index c8be427..60f2e28 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/AttachmentDownloaderWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt @@ -17,12 +17,13 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.* import io.heckel.ntfy.util.queryFilename +import kotlinx.coroutines.delay import okhttp3.OkHttpClient import okhttp3.Request import java.io.File 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() .callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request .connectTimeout(15, TimeUnit.SECONDS) @@ -83,6 +84,14 @@ class AttachmentDownloadWorker(private val context: Context, params: WorkerParam var lastProgress = 0L while (bytes >= 0) { 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 newAttachment = attachment.copy(progress = progress) val newNotification = notification.copy(attachment = newAttachment) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt index fa5a23d..594a129 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationDispatcher.kt @@ -44,10 +44,7 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { } } if (download) { - // 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. - scheduleAttachmentDownload(notification) + DownloadManager.enqueue(context, notification.id) } } @@ -85,15 +82,6 @@ class NotificationDispatcher(val context: Context, val repository: Repository) { 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 { private const val TAG = "NtfyNotifDispatch" } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index 3b92521..9a1e92a 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -9,9 +9,6 @@ import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat 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.data.* import io.heckel.ntfy.data.Notification @@ -66,6 +63,7 @@ class NotificationService(val context: Context) { maybeAddOpenAction(builder, notification) maybeAddBrowseAction(builder, notification) maybeAddDownloadAction(builder, notification) + maybeAddCancelAction(builder, notification) maybeCreateNotificationChannel(notification.priority) notificationManager.notify(notification.notificationId, builder.build()) @@ -164,7 +162,7 @@ class NotificationService(val context: Context) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) { 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) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) 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) { if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { val intent = Intent(context, DownloadBroadcastReceiver::class.java) + intent.putExtra("action", DOWNLOAD_ACTION_START) 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_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() { override fun onReceive(context: Context, intent: Intent) { val id = intent.getStringExtra("id") ?: return - Log.d(TAG, "Enqueuing work to download attachment for notification $id") - val workManager = WorkManager.getInstance(context) - val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java) - .setInputData(workDataOf("id" to id)) - .build() - workManager.enqueue(workRequest) + val action = intent.getStringExtra("action") ?: return + when (action) { + DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id) + DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id) + } } } @@ -254,7 +262,8 @@ class NotificationService(val context: Context) { companion object { 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_LOW = "ntfy-low" 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 fcf4334..b1b283e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -2,7 +2,6 @@ package io.heckel.ntfy.ui import android.Manifest import android.app.Activity -import android.app.DownloadManager import android.content.* import android.content.pm.PackageManager import android.graphics.Bitmap @@ -25,7 +24,8 @@ import androidx.work.workDataOf import com.stfalcon.imageviewer.StfalconImageViewer import io.heckel.ntfy.R 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 java.util.* @@ -182,10 +182,12 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi val popup = PopupMenu(context, anchor) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) + val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) val openItem = popup.menu.findItem(R.id.detail_item_menu_open) val browseItem = popup.menu.findItem(R.id.detail_item_menu_browse) val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000 + val inProgress = attachment.progress in 0..99 if (attachment.contentUri != null) { openItem.setOnMenuItemClickListener { try { @@ -207,7 +209,7 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi } } 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) context.startActivity(intent) 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) return@setOnMenuItemClickListener true } - scheduleAttachmentDownload(context, notification) + DownloadManager.enqueue(context, notification.id) + true + } + cancelItem.setOnMenuItemClickListener { + DownloadManager.cancel(context, notification.id) true } openItem.isVisible = exists browseItem.isVisible = exists - downloadItem.isVisible = !exists && !expired + downloadItem.isVisible = !exists && !expired && !inProgress 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) { return null } @@ -304,15 +311,6 @@ class DetailAdapter(private val activity: Activity, private val onClick: (Notifi 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() { diff --git a/app/src/main/res/menu/menu_detail_attachment.xml b/app/src/main/res/menu/menu_detail_attachment.xml index 0b12998..e0c6fe0 100644 --- a/app/src/main/res/menu/menu_detail_attachment.xml +++ b/app/src/main/res/menu/menu_detail_attachment.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b26aa8c..1c85925 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,6 +112,7 @@ Open file Browse file Download file + Cancel download Copy URL Copied URL to clipboard Cannot open or download attachment. Link expired and no local file found. @@ -166,6 +167,7 @@ Open Browse Download + Cancel %1$s\nFile: %2$s Downloading %1$s, %2$d%%\n%3$s %1$s\nFile: %2$s, download successful