Support opds (#133)

* Add OPDS catalog support for book discovery and downloading

* Implement OPDS search, improved book discovery, and enhanced UI details.

* Improve OPDS search and catalog management

* Add authentication support and extended metadata to the OPDS reader.

* Add support for OPDS 2.0 (JSON) feeds

* Enhance OPDS book downloading with progress tracking and multi-format support.

* Enhance OPDS book management and UI

* Add support for OPDS-PSE (Page Streaming Extension) streaming

* Improve OPDS streaming and catalog management
This commit is contained in:
Aryan 2026-03-30 21:42:06 +05:30 committed by GitHub
parent 355664fbcc
commit 15264a31ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2208 additions and 33 deletions

View file

@ -3089,14 +3089,17 @@ fun PdfViewerScreen(
try {
withContext(Dispatchers.IO) {
Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri")
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
if (pdfUri.scheme != "opds-pse") {
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
}
val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
if (!isActive) {
doc.close()
currentPfdOpened.close()
currentPfdOpened?.close()
return@withContext
}

View file

@ -24,9 +24,11 @@ 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.zip.ZipFile
import androidx.core.graphics.createBitmap
interface ReaderDocument : AutoCloseable {
suspend fun getPageCount(): Int
@ -68,6 +70,13 @@ interface ReaderWebLinks : AutoCloseable {
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) {
@ -117,13 +126,36 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
}
override fun getNativePointer(): Long {
return try {
val field = pdfPage.javaClass.getDeclaredField("mNativePagePtr")
field.isAccessible = true
field.get(pdfPage) as? Long ?: 0L
} catch (_: Exception) {
0L
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() { pdfPage.close() }
@ -388,4 +420,93 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
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("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() {}
}