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 index fdc71ef55..4932b0a97 100644 --- 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 @@ -20,70 +20,56 @@ */ 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 coil3.disk.DiskCache +import com.vitorpamplona.amethyst.Amethyst 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, + /** + * Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is + * responsible for closing the returned snapshot; while it's open the cache entry cannot be + * evicted, so the underlying file stays valid for `PdfRenderer`. + * + * Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU + * eviction and disk budget as images. + */ + suspend fun fetchSnapshot( url: String, okHttpClient: (String) -> OkHttpClient, - ): File = - withContext(Dispatchers.IO) { - val file = cacheFile(context, url) - if (file.exists() && file.length() > 0) return@withContext file + ): DiskCache.Snapshot { + val diskCache = Amethyst.instance.diskCache + diskCache.openSnapshot(url)?.let { return it } - val request = - Request - .Builder() - .url(url) - .get() - .build() + return withContext(Dispatchers.IO) { + val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url") + try { + 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() + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw IOException("PDF download failed: ${response.code}") + } + diskCache.fileSystem.write(editor.data) { + val bytes = writeAll(response.body.source()) + if (bytes == 0L) throw IOException("PDF download failed: empty response body") + } } + + editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url") + } catch (t: Throwable) { + runCatching { editor.abort() } + throw t } - - 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 index 15855f317..5d01fcb21 100644 --- 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 @@ -47,14 +47,13 @@ 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.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity 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 @@ -65,6 +64,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages. +private const val THUMBNAIL_MAX_DIM_PX = 1600 + data class PdfPreview( val thumbnail: Bitmap, val pageCount: Int, @@ -81,47 +83,55 @@ private sealed class PdfLoadState { data object Failed : PdfLoadState() } -@OptIn(ExperimentalFoundationApi::class) @Composable fun PdfPreviewCard( content: MediaUrlPdf, accountViewModel: AccountViewModel, onOpen: () -> Unit, ) { - val context = LocalContext.current + val showPdf = remember { mutableStateOf(accountViewModel.settings.showImages()) } + + if (showPdf.value) { + LoadedPdfPreviewCard(content, accountViewModel, onOpen) + } else { + PlaceholderPdfCard(content) { showPdf.value = true } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LoadedPdfPreviewCard( + content: MediaUrlPdf, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, +) { val sharePopupExpanded = remember { mutableStateOf(false) } + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val targetWidthPx = + remember(density, configuration) { + val screenPx = + with(density) { + configuration.screenWidthDp.dp + .toPx() + .toInt() + } + screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1) + } + @Suppress("ProduceStateDoesNotAssignValue") - val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url) { + val state by produceState(initialValue = PdfLoadState.Loading, key1 = content.url, key2 = targetWidthPx) { value = try { - val file = - PdfFetcher.fetch(context, content.url) { url -> + PdfFetcher + .fetchSnapshot(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(), - ), - ) - } - } + }.use { snapshot -> + withContext(Dispatchers.IO) { + renderFirstPage(snapshot.data.toFile(), targetWidthPx) } } - } } catch (e: Exception) { if (e is CancellationException) throw e Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e) @@ -136,11 +146,11 @@ fun PdfPreviewCard( onDismiss = { sharePopupExpanded.value = false }, ) + val filename = remember(content.url) { extractFilename(content.url) } + when (val current = state) { is PdfLoadState.Loading -> { - WaitAndDisplay { - DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast) - } + PdfSkeletonCard(filename) } is PdfLoadState.Failed -> { @@ -148,7 +158,6 @@ fun PdfPreviewCard( } is PdfLoadState.Ready -> { - val filename = remember(content.url) { extractFilename(content.url) } Column( modifier = MaterialTheme.colorScheme.innerPostModifier @@ -167,33 +176,7 @@ fun PdfPreviewCard( .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, - ) - } - } + FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount)) Spacer(modifier = DoubleVertSpacer) } @@ -201,10 +184,112 @@ fun PdfPreviewCard( } } -private fun extractFilename(url: String): String { +@Composable +private fun PlaceholderPdfCard( + content: MediaUrlPdf, + onLoad: () -> Unit, +) { + val filename = remember(content.url) { extractFilename(content.url) } + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .combinedClickable(onClick = onLoad, onLongClick = onLoad), + ) { + FilenameRow(filename = filename, subtitle = "Tap to load PDF") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun PdfSkeletonCard(filename: String) { + Column(modifier = MaterialTheme.colorScheme.innerPostModifier.fillMaxWidth()) { + FilenameRow(filename = filename, subtitle = "Loading…") + Spacer(modifier = DoubleVertSpacer) + } +} + +@Composable +private fun FilenameRow( + filename: String, + subtitle: String, +) { + 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 = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private fun renderFirstPage( + file: java.io.File, + targetWidthPx: Int, +): PdfLoadState = + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + val pageCount = renderer.pageCount + if (pageCount <= 0) return@use PdfLoadState.Failed + + renderer.openPage(0).use { page -> + val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) + val bitmap = Bitmap.createBitmap(renderW, renderH, 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(), + ), + ) + } + } + } + +/** + * Scales the page's native point size to fit within [maxDim] on the longest side, preserving + * aspect ratio. Falls back to native size if it's already smaller. + */ +internal fun cappedRenderSize( + pageWidth: Int, + pageHeight: Int, + maxDim: Int, +): Pair { + if (pageWidth <= 0 || pageHeight <= 0) return 1 to 1 + val longest = maxOf(pageWidth, pageHeight) + if (longest <= maxDim) return pageWidth to pageHeight + val scale = maxDim.toFloat() / longest + val w = (pageWidth * scale).toInt().coerceAtLeast(1) + val h = (pageHeight * scale).toInt().coerceAtLeast(1) + return w to h +} + +internal 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" +internal 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 index c9a8a21ff..eb7402988 100644 --- 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 @@ -60,9 +60,9 @@ 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 coil3.disk.DiskCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ShareMediaAction @@ -80,10 +80,12 @@ 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 + +// Hard ceiling on each rendered page bitmap, in pixels. Prevents OOM on very large pages. +private const val VIEWER_MAX_DIM_PX = 2048 private class PdfDocumentHandle( - val file: File, + val snapshot: DiskCache.Snapshot, val pfd: ParcelFileDescriptor, val renderer: PdfRenderer, ) { @@ -91,18 +93,9 @@ private class PdfDocumentHandle( 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) - } + runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) } + runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) } + runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) } } } @@ -136,20 +129,23 @@ private fun PdfViewerContent( 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) + val snapshot = + PdfFetcher.fetchSnapshot(content.url) { url -> + accountViewModel.httpClientBuilder.okHttpClientForPreview(url) + } + try { + val pfd = ParcelFileDescriptor.open(snapshot.data.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(pfd) + PdfDocumentHandle(snapshot, pfd, renderer) + } catch (t: Throwable) { + runCatching { snapshot.close() } + throw t + } } } catch (e: Exception) { if (e is CancellationException) throw e @@ -265,9 +261,7 @@ private fun PdfPageView( 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 (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) 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)