book-reader/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Aryan 8366d76dcd
Windows (#291)
* 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
2026-05-10 10:07:37 +05:30

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() {}
}