From 81d4898f3acc9e2eca3d2d0e32c364142492c0ac Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 3 May 2026 08:05:24 +0200 Subject: [PATCH] Allow PDF downloads, route them to Downloads/Amethyst expose Save-to-Disk button for PDFs add Save-to-Phone row for PDFs --- .../amethyst/ui/actions/MediaSaverToDisk.kt | 44 ++++++++++++------- .../ui/components/ZoomableContentDialog.kt | 22 ++++++++-- .../ui/components/ZoomableContentView.kt | 15 +++++++ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 0d618a0c0..9837d17e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -31,7 +31,7 @@ import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import androidx.core.net.toFile import androidx.core.net.toUri -import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY +import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.AMETHYST_SUBDIRECTORY import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -87,7 +87,7 @@ object MediaSaverToDisk { /** * Saves the image to the gallery. May require a storage permission. * - * @see PICTURES_SUBDIRECTORY + * @see AMETHYST_SUBDIRECTORY */ suspend fun downloadAndSave( url: String, @@ -120,9 +120,9 @@ object MediaSaverToDisk { ?.trim() val realType = - headerType?.takeIf(::isMediaMimeType) - ?: mimeType?.takeIf(::isMediaMimeType) - ?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isMediaMimeType) + headerType?.takeIf(::isSaveableMimeType) + ?: mimeType?.takeIf(::isSaveableMimeType) + ?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType) ?: "" check(realType.isNotBlank()) { "Can't find out the content type" } @@ -154,12 +154,13 @@ object MediaSaverToDisk { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it).orEmpty() } - private fun isMediaMimeType(type: String): Boolean = + private fun isSaveableMimeType(type: String): Boolean = type.isNotBlank() && ( type.startsWith("image/", ignoreCase = true) || type.startsWith("video/", ignoreCase = true) || - type.startsWith("audio/", ignoreCase = true) + type.startsWith("audio/", ignoreCase = true) || + type.equals(PDF_MIME_TYPE, ignoreCase = true) ) @OptIn(ExperimentalUuidApi::class) @@ -205,22 +206,32 @@ object MediaSaverToDisk { contentResolver: ContentResolver, ) { val cleanMimeType = contentType.substringBefore(";").trim() + + val (masterUri, baseDir) = + when { + cleanMimeType.startsWith("image/", ignoreCase = true) -> { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES + } + + cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> { + MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS + } + + else -> { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES + } + } + val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType) put( MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY, + baseDir + File.separatorChar + AMETHYST_SUBDIRECTORY, ) } - val masterUri = - when { - contentType.startsWith("image") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - val uri = contentResolver.insert(masterUri, contentValues) checkNotNull(uri) { "Can't insert the new content" } @@ -244,7 +255,7 @@ object MediaSaverToDisk { val subdirectory = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - PICTURES_SUBDIRECTORY, + AMETHYST_SUBDIRECTORY, ).apply { if (!exists()) mkdirs() } @@ -260,5 +271,6 @@ object MediaSaverToDisk { private fun trimInlineMetaData(url: String): String = url.substringBefore("#") - private const val PICTURES_SUBDIRECTORY = "Amethyst" + private const val AMETHYST_SUBDIRECTORY = "Amethyst" + private const val PDF_MIME_TYPE = "application/pdf" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index aa0f69ef9..332bc16d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner @@ -377,7 +378,9 @@ private fun DialogContent( ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = sharePopupExpanded, myContent, onDismiss = { sharePopupExpanded.value = false }) } + } + if (myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf) { if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) { val localContext = LocalContext.current @@ -447,22 +450,33 @@ private fun showToastOnMain( } } -private suspend fun saveMediaToGallery( +internal suspend fun saveMediaToGallery( content: BaseMediaContent, localContext: Context, accountViewModel: AccountViewModel, ) { val isImage = content is MediaUrlImage || content is MediaLocalImage + val isPdf = content is MediaUrlPdf - val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery - val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video + val success = + when { + isImage -> R.string.image_saved_to_the_gallery + isPdf -> R.string.pdf_saved_to_the_gallery + else -> R.string.video_saved_to_the_gallery + } + val failure = + when { + isImage -> R.string.failed_to_save_the_image + isPdf -> R.string.failed_to_save_the_pdf + else -> R.string.failed_to_save_the_video + } if (content is MediaUrlContent) { MediaSaverToDisk.downloadAndSave( content.url, mimeType = content.mimeType, okHttpClient = { - if (isImage) { + if (isImage || isPdf) { accountViewModel.httpClientBuilder.okHttpClientForImage(it) } else { accountViewModel.httpClientBuilder.okHttpClientForVideo(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index dcfd488d3..1244e6a62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -914,6 +914,21 @@ fun ShareMediaAction( onDismiss() } } + if (content is MediaUrlPdf && videoUri != null && !videoUri.startsWith("file")) { + val appContext = LocalContext.current.applicationContext + M3ActionRow(icon = MaterialSymbols.Download, text = stringRes(R.string.download_to_phone)) { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + saveMediaToGallery(content, appContext, accountViewModel) + } + Toast + .makeText( + appContext, + stringRes(appContext, R.string.media_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + onDismiss() + } + } postNostrUri?.let { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) { scope.launch { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 959849ab3..0502a8da3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -176,6 +176,8 @@ Failed to save the image Video saved to the phone\'s video gallery Failed to save the video + PDF saved to Downloads/Amethyst + Failed to save the PDF Upload Image Upload File Take a picture