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