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:
@@ -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)
|
||||
|
||||
+30
-9
@@ -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
|
||||
}
|
||||
}
|
||||
+210
@@ -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"
|
||||
+305
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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,
|
||||
|
||||
+36
-2
@@ -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",
|
||||
)
|
||||
|
||||
+5
@@ -62,6 +62,11 @@ class VideoSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PdfSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(
|
||||
segment: String,
|
||||
|
||||
+69
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user