* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
624 lines
24 KiB
Kotlin
624 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 {
|
|
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<Bookmark>()
|
|
|
|
override fun close() {}
|
|
}
|