Loading app/build.gradle +2 −2 Original line number Diff line number Diff line Loading @@ -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" Loading app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +12 −31 Original line number Diff line number Diff line 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 Loading @@ -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 Loading Loading @@ -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") Loading Loading @@ -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) Loading Loading @@ -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 } Loading app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +132 −79 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 } } if (notification.click != "") { copyContentsItem.setOnMenuItemClickListener { copyToClipboard(context, notification) 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 (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) { Loading @@ -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) Loading Loading @@ -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>() { Loading app/src/main/java/io/heckel/ntfy/util/Util.kt +13 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) Loading app/src/main/res/menu/menu_detail_attachment.xml +2 −2 Original line number Diff line number Diff line Loading @@ -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> Loading
app/build.gradle +2 −2 Original line number Diff line number Diff line Loading @@ -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" Loading
app/src/main/java/io/heckel/ntfy/msg/DownloadWorker.kt +12 −31 Original line number Diff line number Diff line 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 Loading @@ -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 Loading Loading @@ -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") Loading Loading @@ -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) Loading Loading @@ -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 } Loading
app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +132 −79 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 } } if (notification.click != "") { copyContentsItem.setOnMenuItemClickListener { copyToClipboard(context, notification) 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 (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) { Loading @@ -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) Loading Loading @@ -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>() { Loading
app/src/main/java/io/heckel/ntfy/util/Util.kt +13 −2 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) Loading
app/src/main/res/menu/menu_detail_attachment.xml +2 −2 Original line number Diff line number Diff line Loading @@ -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>