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.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"
}
@@ -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)
@@ -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 {
+2
View File
@@ -176,6 +176,8 @@
<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="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_file">Upload File</string>
<string name="take_a_picture">Take a picture</string>