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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
-63
@@ -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"
|
||||
|
||||
+21
-27
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user