book-reader/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Aryan d7a9cae9e1
v1.0.47 (#279)
* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
2026-05-04 21:55:38 +05:30

620 lines
24 KiB
Kotlin

// UniversalDocument.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import android.os.Build
import com.aryan.reader.FileType
import com.aryan.reader.R
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import me.zhanghai.android.libarchive.Archive
import me.zhanghai.android.libarchive.ArchiveEntry
import me.zhanghai.android.libarchive.ArchiveException
import okhttp3.Request
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipFile
import androidx.core.graphics.createBitmap
interface ReaderDocument : AutoCloseable {
suspend fun getPageCount(): Int
suspend fun openPage(pageIndex: Int): ReaderPage?
suspend fun getTableOfContents(): List<Bookmark>
}
interface ReaderPage : AutoCloseable {
suspend fun getPageWidthPoint(): Int
suspend fun getPageHeightPoint(): Int
suspend fun getPageRotation(): Int
suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean)
suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect
suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF
suspend fun openTextPage(): ReaderTextPage
suspend fun getLinks(): List<ReaderLink>
fun getNativePointer(): Long
}
interface ReaderTextPage : AutoCloseable {
suspend fun textPageCountChars(): Int
suspend fun textPageGetText(startIndex: Int, count: Int): String?
suspend fun textPageGetRectsForRanges(ranges: IntArray): List<ReaderTextRect>?
suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double): Int
suspend fun textPageGetCharBox(index: Int): RectF?
suspend fun textPageGetUnicode(index: Int): Int
suspend fun loadWebLink(): ReaderWebLinks?
}
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
data class ReaderTextRect(val rect: RectF)
interface ReaderWebLinks : AutoCloseable {
suspend fun countWebLinks(): Int
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
suspend fun countRects(linkIndex: Int): Int
suspend fun getRect(linkIndex: Int, rectIndex: Int): RectF
}
object DocumentFactory {
suspend fun loadDocument(context: Context, uri: Uri, type: FileType, password: String?, pdfiumCore: PdfiumCoreKt): ReaderDocument {
if (uri.scheme == "opds-pse") {
val bookId = uri.getQueryParameter("id") ?: UUID.randomUUID().toString()
val urlTemplate = uri.getQueryParameter("url") ?: ""
val count = uri.getQueryParameter("count")?.toIntOrNull() ?: 0
val catalogId = uri.getQueryParameter("catalogId")
return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId)
}
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
}
}
ArchiveDocumentWrapper(cacheFile)
} else {
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
}
}
}
// ================= PDF IMPLEMENTATION =================
private inline fun closePdfiumResource(tag: String, closeBlock: () -> Unit) {
try {
closeBlock()
} catch (e: IllegalStateException) {
if (e.message == "Already closed") {
Timber.tag(tag).d(e, "Ignoring duplicate Pdfium close")
} else {
throw e
}
}
}
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
private val isClosed = AtomicBoolean(false)
override suspend fun getPageCount() = PdfiumEngineProvider.withPdfium {
pdfDocument.getPageCount()
}
override suspend fun openPage(pageIndex: Int): ReaderPage? {
if (isClosed.get()) return null
val page = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
} ?: return null
return PdfPageWrapper(page)
}
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
pdfDocument.getFixedTableOfContents()
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfDocumentWrapper") { pdfDocument.close() }
}
}
}
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
private val isClosed = AtomicBoolean(false)
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
}
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
}
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageRotation()
}
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
PdfiumEngineProvider.withPdfium {
if (!isClosed.get()) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
}
}
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
PdfiumEngineProvider.withPdfium {
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
}
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
PdfiumEngineProvider.withPdfium {
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
}
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
}
override suspend fun getLinks(): List<ReaderLink> {
return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) {
emptyList()
} else {
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
}
}
}
override fun getNativePointer(): Long {
return extractNativePointer(pdfPage)
}
private fun extractNativePointer(obj: Any): Long {
val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage")
for (name in priorityFields) {
try {
val field = obj.javaClass.getDeclaredField(name)
field.isAccessible = true
val value = field.get(obj)
if (value is Long && value != 0L) return value
if (value != null && value !is Long) {
val nestedPtr = extractNativePointer(value)
if (nestedPtr != 0L) return nestedPtr
}
} catch (_: Exception) {}
}
try {
for (field in obj.javaClass.declaredFields) {
if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) {
field.isAccessible = true
val value = field.get(obj) as Long
if (value > 0xFFFFFFFFL) return value
}
}
} catch (_: Exception) {}
return 0L
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
}
}
}
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
private val isClosed = AtomicBoolean(false)
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageCountChars()
}
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
}
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
}
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
}
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
}
override suspend fun textPageGetUnicode(index: Int): Int {
return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
}
}
override suspend fun loadWebLink(): ReaderWebLinks? {
val links = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.loadWebLink()
} ?: return null
return object : ReaderWebLinks {
private val isClosed = AtomicBoolean(false)
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countWebLinks()
}
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
}
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countRects(linkIndex)
}
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
}
}
}
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
}
}
}
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
class DummyTextPage : ReaderTextPage {
override suspend fun textPageCountChars() = 0
override suspend fun textPageGetText(startIndex: Int, count: Int) = null
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = null
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = -1
override suspend fun textPageGetCharBox(index: Int) = null
override suspend fun textPageGetUnicode(index: Int) = 0
override suspend fun loadWebLink() = null
override fun close() {}
}
class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
private val imageEntries = mutableListOf<String>()
private var zipFile: ZipFile? = null
private var extractedDir: File? = null
init {
// Try reading as ZIP first for instant O(1) random access (Handles .cbz efficiently)
try {
val zf = ZipFile(file)
val entries = zf.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (!entry.isDirectory && entry.name.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
imageEntries.add(entry.name)
}
}
if (imageEntries.isNotEmpty()) {
zipFile = zf
imageEntries.sort()
} else {
zf.close()
}
} catch (_: Exception) {
zipFile = null
}
if (zipFile == null) {
imageEntries.clear()
extractedDir = File(file.parentFile, "extracted_${file.name}_${System.currentTimeMillis()}")
extractedDir?.mkdirs()
var archive = 0L
try {
archive = Archive.readNew()
Archive.readSupportFilterAll(archive)
Archive.readSupportFormatAll(archive)
Archive.readOpenFileName(archive, file.absolutePath.toByteArray(), 10240)
val tempEntries = mutableListOf<Pair<String, File>>()
while (true) {
val entry = try {
Archive.readNextHeader(archive)
} catch (e: ArchiveException) {
if (e.code == Archive.ERRNO_EOF) break
throw e
}
if (entry == 0L) break
val path = ArchiveEntry.pathnameUtf8(entry)
if (path != null && path.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
val extractedFile = File(extractedDir, UUID.randomUUID().toString() + ".img")
tempEntries.add(Pair(path, extractedFile))
var pfd: android.os.ParcelFileDescriptor? = null
@Suppress("ConvertTryFinallyToUseCall") try {
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
Archive.readDataIntoFd(archive, pfd.fd)
} finally {
pfd?.close()
}
} else {
Archive.readDataSkip(archive)
}
}
tempEntries.sortBy { it.first }
tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
} catch (e: Exception) {
Timber.e(e, "Failed to extract archive entries")
} finally {
if (archive != 0L) Archive.readFree(archive)
}
}
}
override suspend fun getPageCount() = imageEntries.size
override suspend fun openPage(pageIndex: Int): ReaderPage? = withContext(Dispatchers.IO) {
if (pageIndex !in imageEntries.indices) return@withContext null
val targetPath = imageEntries[pageIndex]
var imageBytes: ByteArray? = null
if (zipFile != null) {
try {
val entry = zipFile!!.getEntry(targetPath)
if (entry != null) {
zipFile!!.getInputStream(entry).use { imageBytes = it.readBytes() }
}
} catch (e: Exception) {
Timber.e(e, "Failed to extract page from ZIP")
}
} else {
try {
val extractedFile = File(targetPath)
if (extractedFile.exists()) {
imageBytes = extractedFile.readBytes()
}
} catch (e: Exception) {
Timber.e(e, "Failed to read extracted page")
}
}
if (imageBytes != null && imageBytes!!.isNotEmpty()) ArchivePageWrapper(imageBytes!!) else null
}
override suspend fun getTableOfContents() = emptyList<Bookmark>()
override fun close() {
try { zipFile?.close() } catch (_: Exception) {}
try { extractedDir?.deleteRecursively() } catch (_: Exception) {}
try { file.delete() } catch (_: Exception) {}
}
}
class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
private val decoder = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size, false)
}
} catch (_: Exception) {
null
}
private val originalWidth = decoder?.width ?: 1
private val originalHeight = decoder?.height ?: 1
override suspend fun getPageWidthPoint() = originalWidth
override suspend fun getPageHeightPoint() = originalHeight
override suspend fun getPageRotation() = 0
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
if (decoder == null || decoder.isRecycled) return
val scaleX = drawSizeX.toFloat() / originalWidth
val scaleY = drawSizeY.toFloat() / originalHeight
val pageOffsetX = -startX.toFloat()
val pageOffsetY = -startY.toFloat()
val exactSrcLeft = pageOffsetX / scaleX
val exactSrcTop = pageOffsetY / scaleY
val exactSrcRight = (pageOffsetX + bitmap.width) / scaleX
val exactSrcBottom = (pageOffsetY + bitmap.height) / scaleY
val srcLeft = kotlin.math.floor(exactSrcLeft).toInt().coerceAtLeast(0)
val srcTop = kotlin.math.floor(exactSrcTop).toInt().coerceAtLeast(0)
val srcRight = kotlin.math.ceil(exactSrcRight).toInt().coerceAtMost(originalWidth)
val srcBottom = kotlin.math.ceil(exactSrcBottom).toInt().coerceAtMost(originalHeight)
val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
if (rect.width() <= 0 || rect.height() <= 0) return
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inScaled = false
@Suppress("DEPRECATION")
inDither = true
var sampleSize = 1
val srcWidth = rect.width()
val srcHeight = rect.height()
if (srcHeight > bitmap.height || srcWidth > bitmap.width) {
val halfHeight = srcHeight / 2
val halfWidth = srcWidth / 2
while (halfHeight / sampleSize >= bitmap.height && halfWidth / sampleSize >= bitmap.width) {
sampleSize *= 2
}
}
inSampleSize = sampleSize
}
val region = try {
decoder.decodeRegion(rect, options)
} catch (_: Exception) {
null
}
if (region != null) {
val canvas = Canvas(bitmap)
val destLeft = (srcLeft * scaleX) - pageOffsetX
val destTop = (srcTop * scaleY) - pageOffsetY
val destRight = (srcRight * scaleX) - pageOffsetX
val destBottom = (srcBottom * scaleY) - pageOffsetY
val destRect = RectF(destLeft, destTop, destRight, destBottom)
val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG)
canvas.drawBitmap(region, null, destRect, paint)
region.recycle()
}
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect {
val scaleX = sizeX.toFloat() / originalWidth
val scaleY = sizeY.toFloat() / originalHeight
return Rect(
(startX + coords.left * scaleX).toInt(),
(startY + coords.top * scaleY).toInt(),
(startX + coords.right * scaleX).toInt(),
(startY + coords.bottom * scaleY).toInt()
)
}
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF {
val scaleX = sizeX.toFloat() / originalWidth
val scaleY = sizeY.toFloat() / originalHeight
return PointF((deviceX - startX) / scaleX, (deviceY - startY) / scaleY)
}
override suspend fun openTextPage() = DummyTextPage()
override suspend fun getLinks() = emptyList<ReaderLink>()
override fun getNativePointer() = 0L
override fun close() {
decoder?.recycle()
}
}
class OpdsStreamDocumentWrapper(
private val context: Context,
private val bookId: String,
private val urlTemplate: String,
private val pageCount: Int,
private val catalogId: String?
) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
private val catalog = catalogId?.let {
com.aryan.reader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
}
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password))
}
}
.build()
private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200)
val canvas = Canvas(bitmap)
canvas.drawColor(android.graphics.Color.DKGRAY)
val paint = Paint().apply {
color = android.graphics.Color.WHITE
textSize = 40f
textAlign = Paint.Align.CENTER
}
canvas.drawText(context.getString(R.string.msg_page_unavailable), 400f, 600f, paint)
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, stream)
return stream.toByteArray()
}
override suspend fun getPageCount() = pageCount
override suspend fun openPage(pageIndex: Int): ReaderPage? = withContext(Dispatchers.IO) {
if (pageIndex !in 0 until pageCount) return@withContext null
val cachedFile = File(cacheDir, "page_$pageIndex.jpg")
if (cachedFile.exists() && cachedFile.length() > 0) {
try {
return@withContext ArchivePageWrapper(cachedFile.readBytes())
} catch (e: Exception) {
Timber.e(e, "Failed to read cached stream page")
}
}
val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(catalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)
} catch (_: Exception) {
urlTemplate
}
} else urlTemplate
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600")
val request = Request.Builder().url(url).build()
try {
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val bytes = response.body?.bytes()
if (bytes != null && bytes.isNotEmpty()) {
cachedFile.writeBytes(bytes)
return@withContext ArchivePageWrapper(bytes)
}
} else {
Timber.e("Stream page failed with HTTP ${response.code}")
}
} catch (e: Exception) {
Timber.e(e, "Failed to fetch stream page $pageIndex")
}
return@withContext ArchivePageWrapper(createErrorPageBytes())
}
override suspend fun getTableOfContents() = emptyList<Bookmark>()
override fun close() {}
}