feat: preview PDF links inline with first-page thumbnail and full pager

Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
This commit is contained in:
Claude
2026-04-18 23:06:49 +00:00
parent 8c71d490a7
commit ecdbc80fc1
11 changed files with 772 additions and 11 deletions
@@ -71,6 +71,8 @@ class UrlPreview {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else if (mimeType.type == "application" && mimeType.subtype == "pdf") {
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.layout.ContentScale
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.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -112,6 +113,15 @@ fun RenderLoaded(
accountViewModel = accountViewModel,
)
}
} else if (state.previewInfo.mimeType.startsWith("application/pdf")) {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlPdf(url, uri = callbackUri, mimeType = state.previewInfo.mimeType),
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
} else {
UrlPreviewCard(url, state.previewInfo)
}
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
@@ -480,6 +481,9 @@ private fun RenderWordWithoutPreview(
// Don't preview Videos
is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText)
// Don't preview PDFs
is PdfSegment -> ClickableUrl(word.segmentText, word.segmentText)
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
@@ -529,6 +533,7 @@ private fun RenderWordWithPreview(
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
@@ -86,6 +86,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.images.BlurhashWrapper
@@ -93,6 +94,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard
import com.vitorpamplona.amethyst.ui.components.pdf.PdfViewerDialog
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
@@ -221,18 +224,36 @@ fun ZoomableContentView(
}
}
}
is MediaUrlPdf -> {
Box(modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier)) {
PdfPreviewCard(
content = content,
accountViewModel = accountViewModel,
onOpen = { dialogOpen = true },
)
}
}
}
if (dialogOpen) {
ZoomableImageDialog(
imageUrl = content,
allImages = images,
sourceBounds = sourceBounds,
onDismiss = {
dialogOpen = false
},
accountViewModel = accountViewModel,
)
if (content is MediaUrlPdf) {
PdfViewerDialog(
content = content,
accountViewModel = accountViewModel,
onDismiss = { dialogOpen = false },
)
} else {
ZoomableImageDialog(
imageUrl = content,
allImages = images,
sourceBounds = sourceBounds,
onDismiss = {
dialogOpen = false
},
accountViewModel = accountViewModel,
)
}
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.pdf
import android.content.Context
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.sink
import java.io.File
import java.io.IOException
object PdfFetcher {
private const val CACHE_DIR_NAME = "pdf_cache"
private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() }
fun cachedFileOrNull(
context: Context,
url: String,
): File? {
val file = cacheFile(context, url)
return if (file.exists() && file.length() > 0) file else null
}
fun cacheFile(
context: Context,
url: String,
): File {
val key = sha256(url.toByteArray()).toHexKey()
return File(cacheDir(context), "$key.pdf")
}
suspend fun fetch(
context: Context,
url: String,
okHttpClient: (String) -> OkHttpClient,
): File =
withContext(Dispatchers.IO) {
val file = cacheFile(context, url)
if (file.exists() && file.length() > 0) return@withContext file
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw IOException("PDF download failed: ${response.code}")
}
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.outputStream().use { out ->
val bytes = response.body.source().readAll(out.sink())
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
}
if (!tmp.renameTo(file)) {
tmp.copyTo(file, overwrite = true)
tmp.delete()
}
}
file
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.DisplayUrlWithLoadingSymbol
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class PdfPreview(
val thumbnail: Bitmap,
val pageCount: Int,
val aspectRatio: Float,
)
private sealed class PdfLoadState {
data object Loading : PdfLoadState()
data class Ready(
val preview: PdfPreview,
) : PdfLoadState()
data object Failed : PdfLoadState()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PdfPreviewCard(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
) {
val context = LocalContext.current
val sharePopupExpanded = remember { mutableStateOf(false) }
@Suppress("ProduceStateDoesNotAssignValue")
val state by produceState<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url) {
value =
try {
val file =
PdfFetcher.fetch(context, content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
withContext(Dispatchers.IO) {
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
PdfRenderer(pfd).use { renderer ->
val pageCount = renderer.pageCount
if (pageCount <= 0) {
PdfLoadState.Failed
} else {
renderer.openPage(0).use { page ->
val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
PdfLoadState.Ready(
PdfPreview(
thumbnail = bitmap,
pageCount = pageCount,
aspectRatio = page.width.toFloat() / page.height.toFloat(),
),
)
}
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
PdfLoadState.Failed
}
}
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
when (val current = state) {
is PdfLoadState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
is PdfLoadState.Failed -> {
ClickableUrl(urlText = content.url, url = content.url)
}
is PdfLoadState.Ready -> {
val filename = remember(content.url) { extractFilename(content.url) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = onOpen,
onLongClick = { sharePopupExpanded.value = true },
),
) {
Image(
bitmap = current.preview.thumbnail.asImageBitmap(),
contentDescription = content.description ?: filename,
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
)
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Outlined.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = pageCountLabel(current.preview.pageCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
Spacer(modifier = DoubleVertSpacer)
}
}
}
}
private fun extractFilename(url: String): String {
val afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name
}
private fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
@@ -0,0 +1,305 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
private class PdfDocumentHandle(
val file: File,
val pfd: ParcelFileDescriptor,
val renderer: PdfRenderer,
) {
val pageCount: Int get() = renderer.pageCount
val mutex: Mutex = Mutex()
fun close() {
try {
renderer.close()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to close PdfRenderer", e)
}
try {
pfd.close()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to close ParcelFileDescriptor", e)
}
}
}
@Composable
fun PdfViewerDialog(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
PdfViewerContent(
content = content,
accountViewModel = accountViewModel,
onDismiss = onDismiss,
)
}
}
}
@Composable
private fun PdfViewerContent(
content: MediaUrlPdf,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
@Suppress("ProduceStateDoesNotAssignValue")
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
value =
try {
val file =
PdfFetcher.fetch(context, content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
withContext(Dispatchers.IO) {
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
val renderer = PdfRenderer(pfd)
PdfDocumentHandle(file, pfd, renderer)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to open PDF: ${content.url}", e)
null
}
}
DisposableEffect(handleState) {
onDispose {
handleState?.close()
}
}
val sharePopupExpanded = remember { mutableStateOf(false) }
ShareMediaAction(
accountViewModel = accountViewModel,
popupExpanded = sharePopupExpanded,
content = content,
onDismiss = { sharePopupExpanded.value = false },
)
val handle = handleState
if (handle == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
} else if (handle.pageCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Unable to open PDF",
color = Color.White,
)
}
} else {
val pagerState = rememberPagerState { handle.pageCount }
val pageCache = remember(handle) { mutableStateMapOf<Int, Bitmap>() }
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { pageIndex ->
PdfPageView(
handle = handle,
pageIndex = pageIndex,
cache = pageCache,
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = Size15dp, vertical = Size10dp)
.statusBarsPadding()
.systemBarsPadding(),
horizontalArrangement = spacedBy(Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
color = Color.White,
modifier =
Modifier
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
.padding(horizontal = Size10dp, vertical = Size5dp),
)
Spacer(modifier = Modifier.weight(1f))
OutlinedButton(
onClick = { sharePopupExpanded.value = true },
contentPadding = PaddingValues(horizontal = Size5dp),
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
}
}
}
}
@Composable
private fun PdfPageView(
handle: PdfDocumentHandle,
pageIndex: Int,
cache: MutableMap<Int, Bitmap>,
) {
val cached = cache[pageIndex]
@Suppress("ProduceStateDoesNotAssignValue")
val bitmap by produceState<Bitmap?>(initialValue = cached, key1 = handle, key2 = pageIndex) {
if (value != null) return@produceState
val rendered =
try {
handle.mutex.withLock {
withContext(Dispatchers.IO) {
handle.renderer.openPage(pageIndex).use { page ->
val scale = 2f
val width = (page.width * scale).toInt().coerceAtLeast(1)
val height = (page.height * scale).toInt().coerceAtLeast(1)
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bmp
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("PdfViewerDialog", "Failed to render page $pageIndex", e)
null
}
rendered?.let { cache[pageIndex] = it }
value = rendered
}
val zoomState = rememberZoomState()
val current = bitmap
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (current != null) {
Image(
bitmap = current.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier =
Modifier
.fillMaxSize()
.zoomable(zoomState),
)
} else {
CircularProgressIndicator(color = Color.White)
}
}
}
@@ -68,6 +68,17 @@ class EncryptedMediaUrlImage(
val encryptionNonce: ByteArray,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
@Immutable
open class MediaUrlPdf(
url: String,
description: String? = null,
hash: String? = null,
blurhash: String? = null,
dim: DimensionTag? = null,
uri: String? = null,
mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
@Immutable
open class MediaUrlVideo(
url: String,
@@ -58,17 +58,21 @@ class RichTextParser {
val isImage: Boolean
val isVideo: Boolean
val isPdf: Boolean
if (contentType != null) {
isImage = contentType.startsWith("image/")
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
isPdf = contentType.startsWith("application/pdf")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
isPdf = fullUrl.startsWith("data:application/pdf")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
@@ -93,6 +97,16 @@ class RichTextParser {
uri = callbackUri,
mimeType = contentType,
)
} else if (isPdf) {
MediaUrlPdf(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
uri = callbackUri,
mimeType = contentType,
)
} else {
null
}
@@ -158,6 +172,7 @@ class RichTextParser {
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
@@ -165,7 +180,7 @@ class RichTextParser {
val newContent = fixMissingSpaces(content, allUrls)
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags)
val mediaForPagerWithBase64 =
mediaForPager +
@@ -197,6 +212,7 @@ class RichTextParser {
content: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -211,7 +227,7 @@ class RichTextParser {
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
@@ -262,6 +278,7 @@ class RichTextParser {
word: String,
images: Set<String>,
videos: Set<String>,
pdfs: Set<String>,
urls: Urls,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
@@ -288,6 +305,14 @@ class RichTextParser {
}
}
if (pdfs.contains(word)) {
return if (urls.withoutScheme.contains(word)) {
PdfSegment("https://$word")
} else {
PdfSegment(word)
}
}
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
if (urls.withScheme.contains(word)) return LinkSegment(word)
@@ -377,9 +402,11 @@ class RichTextParser {
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
val pdfExt = listOf("pdf")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
@@ -421,6 +448,11 @@ class RichTextParser {
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isPdfUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
try {
if (url != null) {
@@ -496,4 +528,6 @@ val mimeTypeMap: Map<String, String> =
"m4a" to "audio/mp4",
"aac" to "audio/aac",
"flac" to "audio/flac",
// Documents
"pdf" to "application/pdf",
)
@@ -62,6 +62,11 @@ class VideoSegment(
segment: String,
) : Segment(segment)
@Immutable
class PdfSegment(
segment: String,
) : Segment(segment)
@Immutable
class LinkSegment(
segment: String,
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PdfParserTest {
@Test
fun detectsPdfByExtension() {
val url = "https://example.com/docs/paper.pdf"
val state = RichTextParser().parseText(url, EmptyTagList, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL")
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}")
assertEquals(url, segment.segmentText)
}
@Test
fun detectsPdfFromImetaMimeTypeWithoutExtension() {
val url = "https://files.example.com/abcd1234"
val tags =
ImmutableListOfLists(
arrayOf(
arrayOf("imeta", "url $url", "m application/pdf"),
),
)
val state = RichTextParser().parseText(url, tags, null)
val pdfMedia = state.mediaForPager[url]
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag")
assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType)
val segment = state.paragraphs[0].words[0]
assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}")
}
@Test
fun isPdfUrlHelperMatchesPdfExtension() {
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF"))
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc"))
}
}