diff --git a/app/build.gradle b/app/build.gradle index eea699c..e178e2e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt index 2d3028f..a8eed41 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt @@ -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 } 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 f910e9b..e855ed1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -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() { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index fa2b40c..2316438 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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) diff --git a/app/src/main/res/menu/menu_detail_attachment.xml b/app/src/main/res/menu/menu_detail_attachment.xml index 1601ad5..d67fe91 100644 --- a/app/src/main/res/menu/menu_detail_attachment.xml +++ b/app/src/main/res/menu/menu_detail_attachment.xml @@ -3,8 +3,8 @@ - + - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f99532..e456a2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,19 +138,21 @@ Notification deleted Undo Open file - Browse file Delete file Download file Cancel download + Save file Copy URL Copied URL to clipboard Copy notification Copied notification clipboard + Saved as %1$s to Downloads folder Cannot open or download attachment. Link expired and no local file found. Cannot open attachment: %1$s Cannot open attachment: File may have been deleted, or there is no app to open the file. Cannot open click URL: %1$s - Cannot delete attachment: %1$s + Cannot save attachment: %1$s + Cannot delete attachment: %1$s Attachment download failed: %1$s not downloaded not downloaded, link expired diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 42876b8..a5b6b20 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ - + + diff --git a/fastlane/metadata/android/en-US/changelog/25.txt b/fastlane/metadata/android/en-US/changelog/25.txt new file mode 100644 index 0000000..4a77fb4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelog/25.txt @@ -0,0 +1,2 @@ +Features: +* Download attachments to cache folder (#181)