Allow PDF downloads, route them to Downloads/Amethyst

expose Save-to-Disk button for PDFs
add Save-to-Phone row for PDFs
This commit is contained in:
davotoula
2026-05-03 08:05:24 +02:00
parent 6383a001eb
commit 81d4898f3a
4 changed files with 63 additions and 20 deletions
@@ -31,7 +31,7 @@ import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -87,7 +87,7 @@ object MediaSaverToDisk {
/** /**
* Saves the image to the gallery. May require a storage permission. * Saves the image to the gallery. May require a storage permission.
* *
* @see PICTURES_SUBDIRECTORY * @see AMETHYST_SUBDIRECTORY
*/ */
suspend fun downloadAndSave( suspend fun downloadAndSave(
url: String, url: String,
@@ -120,9 +120,9 @@ object MediaSaverToDisk {
?.trim() ?.trim()
val realType = val realType =
headerType?.takeIf(::isMediaMimeType) headerType?.takeIf(::isSaveableMimeType)
?: mimeType?.takeIf(::isMediaMimeType) ?: mimeType?.takeIf(::isSaveableMimeType)
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isMediaMimeType) ?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
?: "" ?: ""
check(realType.isNotBlank()) { "Can't find out the content type" } check(realType.isNotBlank()) { "Can't find out the content type" }
@@ -154,12 +154,13 @@ object MediaSaverToDisk {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(it).orEmpty() MimeTypeMap.getSingleton().getMimeTypeFromExtension(it).orEmpty()
} }
private fun isMediaMimeType(type: String): Boolean = private fun isSaveableMimeType(type: String): Boolean =
type.isNotBlank() && type.isNotBlank() &&
( (
type.startsWith("image/", ignoreCase = true) || type.startsWith("image/", ignoreCase = true) ||
type.startsWith("video/", 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) @OptIn(ExperimentalUuidApi::class)
@@ -205,22 +206,32 @@ object MediaSaverToDisk {
contentResolver: ContentResolver, contentResolver: ContentResolver,
) { ) {
val cleanMimeType = contentType.substringBefore(";").trim() 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 = val contentValues =
ContentValues().apply { ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType) put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType)
put( put(
MediaStore.MediaColumns.RELATIVE_PATH, 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) val uri = contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) { "Can't insert the new content" } checkNotNull(uri) { "Can't insert the new content" }
@@ -244,7 +255,7 @@ object MediaSaverToDisk {
val subdirectory = val subdirectory =
File( File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY, AMETHYST_SUBDIRECTORY,
).apply { ).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@@ -260,5 +271,6 @@ object MediaSaverToDisk {
private fun trimInlineMetaData(url: String): String = url.substringBefore("#") 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"
} }
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage 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.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner 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 }) 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)) { if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
val localContext = LocalContext.current val localContext = LocalContext.current
@@ -447,22 +450,33 @@ private fun showToastOnMain(
} }
} }
private suspend fun saveMediaToGallery( internal suspend fun saveMediaToGallery(
content: BaseMediaContent, content: BaseMediaContent,
localContext: Context, localContext: Context,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val isImage = content is MediaUrlImage || content is MediaLocalImage 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 success =
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video 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) { if (content is MediaUrlContent) {
MediaSaverToDisk.downloadAndSave( MediaSaverToDisk.downloadAndSave(
content.url, content.url,
mimeType = content.mimeType, mimeType = content.mimeType,
okHttpClient = { okHttpClient = {
if (isImage) { if (isImage || isPdf) {
accountViewModel.httpClientBuilder.okHttpClientForImage(it) accountViewModel.httpClientBuilder.okHttpClientForImage(it)
} else { } else {
accountViewModel.httpClientBuilder.okHttpClientForVideo(it) accountViewModel.httpClientBuilder.okHttpClientForVideo(it)
@@ -914,6 +914,21 @@ fun ShareMediaAction(
onDismiss() 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 { postNostrUri?.let {
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) {
scope.launch { scope.launch {
+2
View File
@@ -176,6 +176,8 @@
<string name="failed_to_save_the_image">Failed to save the image</string> <string name="failed_to_save_the_image">Failed to save the image</string>
<string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string> <string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string>
<string name="failed_to_save_the_video">Failed to save the video</string> <string name="failed_to_save_the_video">Failed to save the video</string>
<string name="pdf_saved_to_the_gallery">PDF saved to Downloads/Amethyst</string>
<string name="failed_to_save_the_pdf">Failed to save the PDF</string>
<string name="upload_image">Upload Image</string> <string name="upload_image">Upload Image</string>
<string name="upload_file">Upload File</string> <string name="upload_file">Upload File</string>
<string name="take_a_picture">Take a picture</string> <string name="take_a_picture">Take a picture</string>