Add cancel to downloads
This commit is contained in:
parent
1cf781b27b
commit
40d8d20cc5
7 changed files with 86 additions and 40 deletions
39
app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt
Normal file
39
app/src/main/java/io/heckel/ntfy/msg/DownloadManager.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>() {
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue