refactor(pdf): share Coil disk cache, gate on showImages, cap bitmap size

- PdfFetcher now reuses Amethyst.instance.diskCache (Coil) via
  openSnapshot/openEditor instead of a custom cacheDir folder. PDFs share
  the same LRU budget as images and benefit from automatic eviction.
- PdfPreviewCard now respects accountViewModel.settings.showImages():
  when disabled, shows a lightweight "Tap to load PDF" placeholder and
  only downloads+renders after the user opts in.
- Both the card thumbnail (1600px) and viewer page (2048px) bitmaps are
  capped to a maximum longest-side dimension, preventing OOM on very
  large or unusually tall PDF pages.
- PdfViewerDialog holds the cache snapshot for the dialog's lifetime so
  the underlying file can't be evicted mid-view, and closes it in
  DisposableEffect alongside the renderer and ParcelFileDescriptor.
This commit is contained in:
Claude
2026-04-18 23:59:50 +00:00
parent ecdbc80fc1
commit 9fbe8eba78
3 changed files with 206 additions and 141 deletions
@@ -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
}
}
}
@@ -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<PdfLoadState>(initialValue = PdfLoadState.Loading, key1 = content.url) {
val state by produceState<PdfLoadState>(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<Int, Int> {
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"
@@ -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<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)
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)