Download attachments to cache folder, allow saving them
This commit is contained in:
parent
ac0ecbdcc1
commit
8339bc9c2a
8 changed files with 169 additions and 119 deletions
|
@ -12,8 +12,8 @@ android {
|
|||
minSdkVersion 21
|
||||
targetSdkVersion 31
|
||||
|
||||
versionCode 24
|
||||
versionName "1.10.0"
|
||||
versionCode 25
|
||||
versionName "1.11.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
|
@ -19,7 +15,6 @@ import io.heckel.ntfy.app.Application
|
|||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.ensureSafeNewFile
|
||||
import io.heckel.ntfy.util.fileName
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
@ -81,24 +76,7 @@ class DownloadWorker(private val context: Context, params: WorkerParameters) : W
|
|||
return
|
||||
}
|
||||
val resolver = applicationContext.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 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")
|
||||
}
|
||||
val uri = createUri(notification)
|
||||
this.uri = uri // Required for cleanup in onStopped()
|
||||
|
||||
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")
|
||||
val actualName = fileName(context, uri.toString(), attachment.name)
|
||||
save(attachment.copy(
|
||||
name = actualName,
|
||||
size = bytesCopied,
|
||||
contentUri = uri.toString(),
|
||||
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) {
|
||||
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 {
|
||||
const val INPUT_DATA_ID = "id"
|
||||
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 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 NOTIFICATION_UPDATE_INTERVAL_MILLIS = 800
|
||||
}
|
||||
|
|
|
@ -8,12 +8,15 @@ import android.graphics.Bitmap
|
|||
import android.graphics.BitmapFactory
|
||||
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.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -21,6 +24,7 @@ import com.stfalcon.imageviewer.StfalconImageViewer
|
|||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadWorker
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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)
|
||||
val attachment = notification.attachment // May be null
|
||||
val hasAttachment = attachment != 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 browseItem = popup.menu.findItem(R.id.detail_item_menu_browse)
|
||||
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_contents)
|
||||
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) {
|
||||
if (attachment.contentUri != null) {
|
||||
openItem.setOnMenuItemClickListener {
|
||||
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()
|
||||
}
|
||||
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
|
||||
}
|
||||
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 (notification.click != "") {
|
||||
copyContentsItem.setOnMenuItemClickListener {
|
||||
copyToClipboard(context, notification)
|
||||
true
|
||||
}
|
||||
if (hasClickLink) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
openItem.isVisible = hasAttachment && exists
|
||||
browseItem.isVisible = hasAttachment && exists
|
||||
downloadItem.isVisible = hasAttachment && !exists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && exists
|
||||
saveFileItem.isVisible = hasAttachment && exists
|
||||
copyUrlItem.isVisible = hasAttachment && !expired
|
||||
cancelItem.isVisible = hasAttachment && inProgress
|
||||
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
|
||||
&& !copyContentsItem.isVisible
|
||||
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 {
|
||||
val name = fileName(context, attachment.contentUri, attachment.name)
|
||||
val name = attachment.name
|
||||
val notYetDownloaded = !exists && attachment.progress == PROGRESS_NONE
|
||||
val downloading = !exists && attachment.progress in 0..99
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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>() {
|
||||
|
|
|
@ -33,6 +33,7 @@ import okhttp3.RequestBody
|
|||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.text.DateFormat
|
||||
|
@ -205,13 +206,23 @@ fun fileName(context: Context, contentUri: String?, fallbackName: String): Strin
|
|||
}
|
||||
|
||||
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 cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
|
||||
return cursor.use { c ->
|
||||
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
||||
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(
|
||||
filename = c.getString(nameIndex),
|
||||
size = c.getLong(sizeIndex)
|
||||
|
|
|
@ -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_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_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_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_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>
|
||||
|
|
|
@ -138,19 +138,21 @@
|
|||
<string name="detail_item_snack_deleted">Notification deleted</string>
|
||||
<string name="detail_item_snack_undo">Undo</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_download">Download file</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_copied">Copied URL to clipboard</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_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_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_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_info_not_downloaded">not downloaded</string>
|
||||
<string name="detail_item_download_info_not_downloaded_expired">not downloaded, link expired</string>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<paths>
|
||||
<external-path name="external_files" path="."/>
|
||||
<cache-path name="cache_files" path="."/>
|
||||
</paths>
|
||||
|
|
2
fastlane/metadata/android/en-US/changelog/25.txt
Normal file
2
fastlane/metadata/android/en-US/changelog/25.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
Features:
|
||||
* Download attachments to cache folder (#181)
|
Loading…
Reference in a new issue