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 package com.vitorpamplona.amethyst.ui.components.pdf
import android.content.Context import coil3.disk.DiskCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync import okhttp3.coroutines.executeAsync
import okio.sink
import java.io.File
import java.io.IOException import java.io.IOException
object PdfFetcher { object PdfFetcher {
private const val CACHE_DIR_NAME = "pdf_cache" /**
* Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is
private fun cacheDir(context: Context): File = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } * responsible for closing the returned snapshot; while it's open the cache entry cannot be
* evicted, so the underlying file stays valid for `PdfRenderer`.
fun cachedFileOrNull( *
context: Context, * Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU
url: String, * eviction and disk budget as images.
): File? { */
val file = cacheFile(context, url) suspend fun fetchSnapshot(
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, url: String,
okHttpClient: (String) -> OkHttpClient, okHttpClient: (String) -> OkHttpClient,
): File = ): DiskCache.Snapshot {
withContext(Dispatchers.IO) { val diskCache = Amethyst.instance.diskCache
val file = cacheFile(context, url) diskCache.openSnapshot(url)?.let { return it }
if (file.exists() && file.length() > 0) return@withContext file
val request = return withContext(Dispatchers.IO) {
Request val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url")
.Builder() try {
.url(url) val request =
.get() Request
.build() .Builder()
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).executeAsync().use { response -> okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("PDF download failed: ${response.code}") throw IOException("PDF download failed: ${response.code}")
} }
val tmp = File(file.parentFile, "${file.name}.tmp") diskCache.fileSystem.write(editor.data) {
tmp.outputStream().use { out -> val bytes = writeAll(response.body.source())
val bytes = response.body.source().readAll(out.sink()) if (bytes == 0L) throw IOException("PDF download failed: empty response body")
if (bytes == 0L) throw IOException("PDF download failed: empty response body") }
}
if (!tmp.renameTo(file)) {
tmp.copyTo(file, overwrite = true)
tmp.delete()
} }
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.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl 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.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
@@ -65,6 +64,9 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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( data class PdfPreview(
val thumbnail: Bitmap, val thumbnail: Bitmap,
val pageCount: Int, val pageCount: Int,
@@ -81,47 +83,55 @@ private sealed class PdfLoadState {
data object Failed : PdfLoadState() data object Failed : PdfLoadState()
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PdfPreviewCard( fun PdfPreviewCard(
content: MediaUrlPdf, content: MediaUrlPdf,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onOpen: () -> Unit, 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 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") @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 = value =
try { try {
val file = PdfFetcher
PdfFetcher.fetch(context, content.url) { url -> .fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url) accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
} }.use { snapshot ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> renderFirstPage(snapshot.data.toFile(), targetWidthPx)
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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e) Log.w("PdfPreviewCard", "Failed to render PDF preview: ${content.url}", e)
@@ -136,11 +146,11 @@ fun PdfPreviewCard(
onDismiss = { sharePopupExpanded.value = false }, onDismiss = { sharePopupExpanded.value = false },
) )
val filename = remember(content.url) { extractFilename(content.url) }
when (val current = state) { when (val current = state) {
is PdfLoadState.Loading -> { is PdfLoadState.Loading -> {
WaitAndDisplay { PdfSkeletonCard(filename)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
} }
is PdfLoadState.Failed -> { is PdfLoadState.Failed -> {
@@ -148,7 +158,6 @@ fun PdfPreviewCard(
} }
is PdfLoadState.Ready -> { is PdfLoadState.Ready -> {
val filename = remember(content.url) { extractFilename(content.url) }
Column( Column(
modifier = modifier =
MaterialTheme.colorScheme.innerPostModifier MaterialTheme.colorScheme.innerPostModifier
@@ -167,33 +176,7 @@ fun PdfPreviewCard(
.aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)), .aspectRatio(current.preview.aspectRatio.coerceAtLeast(0.2f)),
) )
Row( FilenameRow(filename = filename, subtitle = pageCountLabel(current.preview.pageCount))
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) 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 afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery) val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name 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.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
@@ -80,10 +80,12 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable 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( private class PdfDocumentHandle(
val file: File, val snapshot: DiskCache.Snapshot,
val pfd: ParcelFileDescriptor, val pfd: ParcelFileDescriptor,
val renderer: PdfRenderer, val renderer: PdfRenderer,
) { ) {
@@ -91,18 +93,9 @@ private class PdfDocumentHandle(
val mutex: Mutex = Mutex() val mutex: Mutex = Mutex()
fun close() { fun close() {
try { runCatching { renderer.close() }.onFailure { Log.w("PdfViewerDialog", "renderer close failed", it) }
renderer.close() runCatching { pfd.close() }.onFailure { Log.w("PdfViewerDialog", "pfd close failed", it) }
} catch (e: Exception) { runCatching { snapshot.close() }.onFailure { Log.w("PdfViewerDialog", "snapshot close failed", it) }
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)
}
} }
} }
@@ -136,20 +129,23 @@ private fun PdfViewerContent(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val context = LocalContext.current
@Suppress("ProduceStateDoesNotAssignValue") @Suppress("ProduceStateDoesNotAssignValue")
val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) { val handleState by produceState<PdfDocumentHandle?>(initialValue = null, key1 = content.url) {
value = value =
try { try {
val file =
PdfFetcher.fetch(context, content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) val snapshot =
val renderer = PdfRenderer(pfd) PdfFetcher.fetchSnapshot(content.url) { url ->
PdfDocumentHandle(file, pfd, renderer) 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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -265,9 +261,7 @@ private fun PdfPageView(
handle.mutex.withLock { handle.mutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
handle.renderer.openPage(pageIndex).use { page -> handle.renderer.openPage(pageIndex).use { page ->
val scale = 2f val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX)
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) val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(android.graphics.Color.WHITE) bmp.eraseColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)