// 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 } 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 fun getNativePointer(): Long } interface ReaderTextPage : AutoCloseable { suspend fun textPageCountChars(): Int suspend fun textPageGetText(startIndex: Int, count: Int): String? suspend fun textPageGetRectsForRanges(ranges: IntArray): List? 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 { 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() 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>() 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() 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() 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 { val streamCatalog = catalog val username = streamCatalog?.username val password = streamCatalog?.password if (!username.isNullOrBlank() && !password.isNullOrBlank()) { authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, 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 streamCatalog = catalog val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) { try { val oldUrl = java.net.URL(urlTemplate) val newUrl = java.net.URL(streamCatalog.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() override fun close() {} }