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
targetSdkVersion 31
versionCode 24
versionName "1.10.0"
versionCode 25
versionName "1.11.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View file

@ -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
}

View file

@ -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>() {

View file

@ -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)

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_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>

View file

@ -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>

View file

@ -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>

View file

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