From ecdbc80fc1721e79c19668caeb76e47ccfead0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:06:49 +0000 Subject: [PATCH] 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. --- .../amethyst/service/previews/UrlPreview.kt | 2 + .../amethyst/ui/components/LoadUrlPreview.kt | 10 + .../amethyst/ui/components/RichTextViewer.kt | 5 + .../ui/components/ZoomableContentView.kt | 39 ++- .../amethyst/ui/components/pdf/PdfFetcher.kt | 89 +++++ .../ui/components/pdf/PdfPreviewCard.kt | 210 ++++++++++++ .../ui/components/pdf/PdfViewerDialog.kt | 305 ++++++++++++++++++ .../commons/richtext/MediaContentModels.kt | 11 + .../commons/richtext/RichTextParser.kt | 38 ++- .../richtext/RichTextParserSegments.kt | 5 + .../commons/richtext/PdfParserTest.kt | 69 ++++ 11 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt index 5deeb41ee..db4c9ac9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -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") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index b99fb85e9..371d21b58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index fe147eb19..133ecdab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) 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 08f3ccc1c..469afe4ff 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 @@ -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, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt new file mode 100644 index 000000000..fdc71ef55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfFetcher.kt @@ -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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt new file mode 100644 index 000000000..15855f317 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -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(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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt new file mode 100644 index 000000000..c9a8a21ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -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(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() } + + 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, +) { + val cached = cache[pageIndex] + + @Suppress("ProduceStateDoesNotAssignValue") + val bitmap by produceState(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) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index d390a1834..9b1a36cbb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index bfc0b30cf..e832568c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -211,7 +227,7 @@ class RichTextParser { val segments = ArrayList(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, videos: Set, + pdfs: Set, urls: Urls, emojis: Map, tags: ImmutableListOfLists, @@ -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 = "m4a" to "audio/mp4", "aac" to "audio/aac", "flac" to "audio/flac", + // Documents + "pdf" to "application/pdf", ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 79de431fd..c0aa426c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -62,6 +62,11 @@ class VideoSegment( segment: String, ) : Segment(segment) +@Immutable +class PdfSegment( + segment: String, +) : Segment(segment) + @Immutable class LinkSegment( segment: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt new file mode 100644 index 000000000..039d8b4e2 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -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")) + } +}