Download attachments to cache folder, allow saving them

This commit is contained in:
Philipp Heckel 2022-03-21 23:11:37 -04:00
parent ac0ecbdcc1
commit 8339bc9c2a
8 changed files with 169 additions and 119 deletions

View file

@ -12,8 +12,8 @@ android {
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 31 targetSdkVersion 31
versionCode 24 versionCode 25
versionName "1.10.0" versionName "1.11.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View file

@ -1,13 +1,9 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -19,7 +15,6 @@ import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile import io.heckel.ntfy.util.ensureSafeNewFile
import io.heckel.ntfy.util.fileName
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -81,24 +76,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
return return
} }
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
val values = ContentValues().apply { val uri = createUri(notification)
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 uri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
} else {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
}
this.uri = uri // Required for cleanup in onStopped() this.uri = uri // Required for cleanup in onStopped()
Log.d(TAG, "Starting download to content URI: $uri") Log.d(TAG, "Starting download to content URI: $uri")
@ -133,18 +111,11 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
} }
} }
Log.d(TAG, "Attachment download: successful response, proceeding with download") Log.d(TAG, "Attachment download: successful response, proceeding with download")
val actualName = fileName(context, uri.toString(), attachment.name)
save(attachment.copy( save(attachment.copy(
name = actualName,
size = bytesCopied, size = bytesCopied,
contentUri = uri.toString(), contentUri = uri.toString(),
progress = PROGRESS_DONE progress = PROGRESS_DONE
)) ))
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(uri, values, null, null)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
failed(e) failed(e)
@ -217,12 +188,22 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
} }
} }
private fun createUri(notification: Notification): Uri {
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {
throw Exception("Cannot create cache directory for attachments: $attachmentDir")
}
val file = ensureSafeNewFile(attachmentDir, notification.id)
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
}
companion object { companion object {
const val INPUT_DATA_ID = "id" const val INPUT_DATA_ID = "id"
const val INPUT_DATA_USER_ACTION = "userAction" const val INPUT_DATA_USER_ACTION = "userAction"
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
private const val TAG = "NtfyAttachDownload" private const val TAG = "NtfyAttachDownload"
private const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml private const val ATTACHMENT_CACHE_DIR = "attachments"
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024
private const val NOTIFICATION_UPDATE_INTERVAL_MILLIS = 800 private const val NOTIFICATION_UPDATE_INTERVAL_MILLIS = 800
} }

View file

@ -8,12 +8,15 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -21,6 +24,7 @@ import com.stfalcon.imageviewer.StfalconImageViewer
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.DownloadManager import io.heckel.ntfy.msg.DownloadManager
import io.heckel.ntfy.msg.DownloadWorker
import io.heckel.ntfy.util.* import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@ -174,100 +178,35 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
val attachment = notification.attachment // May be null val attachment = notification.attachment // May be null
val hasAttachment = attachment != null val hasAttachment = attachment != null
val hasClickLink = notification.click != ""
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 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 deleteItem = popup.menu.findItem(R.id.detail_item_menu_delete) 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 copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url)
val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_contents) val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_copy_contents)
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 val inProgress = attachment?.progress in 0..99
if (attachment != null) { if (attachment != null) {
if (attachment.contentUri != null) { openItem.setOnMenuItemClickListener { openFile(context, attachment) }
openItem.setOnMenuItemClickListener { saveFileItem.setOnMenuItemClickListener { saveFile(context, attachment) }
try { deleteItem.setOnMenuItemClickListener { deleteFile(context, notification, attachment) }
val contentUri = Uri.parse(attachment.contentUri) copyUrlItem.setOnMenuItemClickListener { copyUrl(context, attachment) }
val intent = Intent(Intent.ACTION_VIEW, contentUri) downloadItem.setOnMenuItemClickListener { downloadFile(context, notification) }
intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P cancelItem.setOnMenuItemClickListener { cancelDownload(context, notification) }
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()
}
true
}
}
browseItem.setOnMenuItemClickListener {
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
true
}
if (attachment.contentUri != null) {
deleteItem.setOnMenuItemClickListener {
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(progress = 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_delete_failed, e.message), Toast.LENGTH_LONG)
.show()
}
true
}
}
copyUrlItem.setOnMenuItemClickListener {
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()
true
}
downloadItem.setOnMenuItemClickListener {
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@setOnMenuItemClickListener true
}
DownloadManager.enqueue(context, notification.id, userAction = true)
true
}
cancelItem.setOnMenuItemClickListener {
DownloadManager.cancel(context, notification.id)
true
}
}
if (notification.click != "") {
copyContentsItem.setOnMenuItemClickListener {
copyToClipboard(context, notification)
true
} }
if (hasClickLink) {
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
} }
openItem.isVisible = hasAttachment && exists openItem.isVisible = hasAttachment && exists
browseItem.isVisible = hasAttachment && exists
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress
deleteItem.isVisible = hasAttachment && exists deleteItem.isVisible = hasAttachment && exists
saveFileItem.isVisible = hasAttachment && exists
copyUrlItem.isVisible = hasAttachment && !expired copyUrlItem.isVisible = hasAttachment && !expired
cancelItem.isVisible = hasAttachment && inProgress cancelItem.isVisible = hasAttachment && inProgress
copyContentsItem.isVisible = notification.click != "" copyContentsItem.isVisible = notification.click != ""
val noOptions = !openItem.isVisible && !browseItem.isVisible && !downloadItem.isVisible val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible && !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
&& !copyContentsItem.isVisible && !copyContentsItem.isVisible
if (noOptions) { if (noOptions) {
@ -277,7 +216,7 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
} }
private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String { private fun formatAttachmentDetails(context: Context, attachment: Attachment, exists: Boolean): String {
val name = fileName(context, attachment.contentUri, attachment.name) val name = attachment.name
val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
val downloading = !exists && attachment.progress in 0..99 val downloading = !exists && attachment.progress in 0..99
val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED) val deleted = !exists && (attachment.progress == PROGRESS_DONE || attachment.progress == PROGRESS_DELETED)
@ -345,6 +284,120 @@ class DetailAdapter(private val activity: Activity, private val repository: Repo
attachmentImageView.visibility = View.GONE attachmentImageView.visibility = View.GONE
} }
} }
private fun openFile(context: Context, attachment: Attachment): Boolean {
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, DownloadWorker.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(progress = 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)
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
}
} }
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() { object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {

View file

@ -33,6 +33,7 @@ import okhttp3.RequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.security.SecureRandom import java.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
@ -205,13 +206,23 @@ fun fileName(context: Context, contentUri: String?, fallbackName: String): Strin
} }
fun fileStat(context: Context, contentUri: Uri?): FileInfo { fun fileStat(context: Context, contentUri: Uri?): FileInfo {
if (contentUri == null) throw Exception("URI is null") if (contentUri == null) {
throw FileNotFoundException("URI is null")
}
val resolver = context.applicationContext.contentResolver val resolver = context.applicationContext.contentResolver
val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null") val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
return cursor.use { c -> return cursor.use { c ->
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE) val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
c.moveToFirst() if (!c.moveToFirst()) {
throw FileNotFoundException("Not found: $contentUri")
}
val size = c.getLong(sizeIndex)
if (size == 0L) {
// Content provider URIs (e.g. content://io.heckel.ntfy.provider/cache_files/DQ4o7DitZAmw) return an entry, even
// when they do not exist, but with an empty size. This is a practical/fast way to weed out non-existing files.
throw FileNotFoundException("Not found or empty: $contentUri")
}
FileInfo( FileInfo(
filename = c.getString(nameIndex), filename = c.getString(nameIndex),
size = c.getLong(sizeIndex) size = c.getLong(sizeIndex)

View file

@ -3,8 +3,8 @@
<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_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_delete" android:title="@string/detail_item_menu_delete"/> <item android:id="@+id/detail_item_menu_delete" android:title="@string/detail_item_menu_delete"/>
<item android:id="@+id/detail_item_menu_save_file" android:title="@string/detail_item_menu_save_file"/>
<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"/>
<item android:id="@+id/detail_item_menu_contents" android:title="@string/detail_item_menu_copy_contents"/> <item android:id="@+id/detail_item_menu_copy_contents" android:title="@string/detail_item_menu_copy_contents"/>
</menu> </menu>

View file

@ -138,19 +138,21 @@
<string name="detail_item_snack_deleted">Notification deleted</string> <string name="detail_item_snack_deleted">Notification deleted</string>
<string name="detail_item_snack_undo">Undo</string> <string name="detail_item_snack_undo">Undo</string>
<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_delete">Delete file</string> <string name="detail_item_menu_delete">Delete 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_cancel">Cancel download</string>
<string name="detail_item_menu_save_file">Save file</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_menu_copy_contents">Copy notification</string> <string name="detail_item_menu_copy_contents">Copy notification</string>
<string name="detail_item_menu_copy_contents_copied">Copied notification clipboard</string> <string name="detail_item_menu_copy_contents_copied">Copied notification clipboard</string>
<string name="detail_item_saved_successfully">Saved as %1$s to Downloads folder</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>
<string name="detail_item_cannot_open">Cannot open attachment: %1$s</string> <string name="detail_item_cannot_open">Cannot open attachment: %1$s</string>
<string name="detail_item_cannot_open_not_found">Cannot open attachment: File may have been deleted, or there is no app to open the file.</string> <string name="detail_item_cannot_open_not_found">Cannot open attachment: File may have been deleted, or there is no app to open the file.</string>
<string name="detail_item_cannot_open_click_url">Cannot open click URL: %1$s</string> <string name="detail_item_cannot_open_click_url">Cannot open click URL: %1$s</string>
<string name="detail_item_delete_failed">Cannot delete attachment: %1$s</string> <string name="detail_item_cannot_save">Cannot save attachment: %1$s</string>
<string name="detail_item_cannot_delete">Cannot delete attachment: %1$s</string>
<string name="detail_item_download_failed">Attachment download failed: %1$s</string> <string name="detail_item_download_failed">Attachment download failed: %1$s</string>
<string name="detail_item_download_info_not_downloaded">not downloaded</string> <string name="detail_item_download_info_not_downloaded">not downloaded</string>
<string name="detail_item_download_info_not_downloaded_expired">not downloaded, link expired</string> <string name="detail_item_download_info_not_downloaded_expired">not downloaded, link expired</string>

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths>
<external-path name="external_files" path="."/> <external-path name="external_files" path="."/>
<cache-path name="cache_files" path="."/>
</paths> </paths>

View file

@ -0,0 +1,2 @@
Features:
* Download attachments to cache folder (#181)