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

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

View file

@ -55,6 +55,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
@ -656,6 +657,27 @@ fun RecentFileCard(
}
}
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
if (isOpdsStream) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = CircleShape
)
.padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Cloud,
contentDescription = "OPDS Stream",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
if (isPinned) {
Box(
modifier = Modifier

File diff suppressed because it is too large Load diff

View file

@ -455,6 +455,46 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
fun streamOpdsBook(
bookId: String,
title: String,
urlTemplate: String,
pageCount: Int,
catalogId: String?
) {
val encodedUrl = Uri.encode(urlTemplate)
val safeId = Uri.encode(bookId)
val catId = catalogId?.let { "&catalogId=${Uri.encode(it)}" } ?: ""
val uriString = "opds-pse://stream?id=$safeId&count=$pageCount&url=$encodedUrl$catId"
openBook(uriString.toUri(), bookId, FileType.CBZ, title)
}
fun deleteStreamedBooksForCatalog(catalogId: String) {
viewModelScope.launch(Dispatchers.IO) {
val filesToDelete = recentFilesRepository.getAllFilesForSync().filter {
it.uriString?.contains("catalogId=$catalogId") == true
}
if (filesToDelete.isNotEmpty()) {
val ids = filesToDelete.map { it.bookId }
ids.forEach { bookId ->
pdfTextRepository.clearBookText(bookId)
clearImportedFileCache(bookId)
try {
val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}")
if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) {
Timber.e(e, "Failed to clean stream cache for $bookId")
}
}
recentFilesRepository.deleteFilePermanently(ids)
withContext(Dispatchers.Main) {
showBanner("Removed ${filesToDelete.size} streaming books.")
}
}
}
}
private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED)
val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow()
private var hasRequestedReviewInThisSession = false
@ -1169,6 +1209,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun uploadSingleBookMetadata(book: RecentFileItem) {
if (!uiState.value.isSyncEnabled) return
if (book.uriString?.startsWith("opds-pse") == true) {
Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
@ -2027,11 +2072,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val localBooks = withContext(Dispatchers.IO) {
val allFiles = recentFilesRepository.getAllFilesForSync()
if (_internalState.value.isFolderSyncEnabled) {
val filtered = if (_internalState.value.isFolderSyncEnabled) {
allFiles
} else {
allFiles.filter { it.sourceFolderUri == null }
}
filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
@ -2424,7 +2470,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (coverBitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
} else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
var cacheFile: File? = null
try {
cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
@ -2524,6 +2570,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
cacheDir.listFiles()?.forEach { file ->
val name = file.name
@ -2538,6 +2585,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
}
} else if (name.startsWith("opds_stream_")) {
val bookIdHash = name.removePrefix("opds_stream_")
if (bookIdHash !in validStreamHashes) {
val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete()
if (deleted) Timber.d("Sweeper cleaned orphaned OPDS stream cache for hash: $bookIdHash")
}
}
}
val legacyExtractedDir = File(cacheDir, "extracted_epubs")
@ -2837,20 +2890,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf")
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
if (uri.scheme != "opds-pse") {
try {
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
Timber.tag("FileOpenPerf")
.d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
}
}
} catch (e: Exception) {
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
} catch (e: Exception) {
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
viewModelScope.launch {

View file

@ -335,8 +335,11 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
}
val context = LocalContext.current
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
if (item.sourceFolderUri != null && item.uriString != null) {
if (isOpdsStream) {
"Source: OPDS Stream"
} else if (item.sourceFolderUri != null && item.uriString != null) {
try {
val uri = item.uriString.toUri()
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {

View file

@ -0,0 +1,93 @@
// OpdsModels.kt
package com.aryan.reader.opds
data class OpdsCatalog(
val id: String,
val title: String,
val url: String,
val isDefault: Boolean = false,
val username: String? = null,
val password: String? = null
)
data class OpdsFacet(
val title: String,
val group: String,
val url: String,
val isActive: Boolean
)
data class OpdsFeed(
val title: String,
val entries: List<OpdsEntry>,
val nextUrl: String?,
val searchUrl: String? = null,
val facets: List<OpdsFacet> = emptyList()
)
data class OpdsAuthor(
val name: String,
val url: String?
)
data class OpdsAcquisition(
val url: String,
val mimeType: String
) {
val formatName: String
get() = when {
mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
mimeType.contains("cbr") || mimeType.contains("rar") -> "CBR"
mimeType.contains("txt") || mimeType.contains("text/plain") -> "TXT"
else -> mimeType.substringAfterLast("/").uppercase()
}
val priority: Int
get() = when (formatName) {
"EPUB" -> 5
"PDF" -> 4
"MOBI" -> 3
"FB2" -> 2
"CBZ" -> 1
"TXT" -> 0
else -> -1
}
}
data class OpdsEntry(
val id: String,
val title: String,
val summary: String?,
val authors: List<OpdsAuthor> = emptyList(),
val coverUrl: String?,
val acquisitions: List<OpdsAcquisition> = emptyList(),
val navigationUrl: String?,
val publisher: String? = null,
val published: String? = null,
val language: String? = null,
val series: String? = null,
val seriesIndex: String? = null,
val categories: List<String> = emptyList(),
// ADD THESE:
val pseCount: Int? = null,
val pseUrlTemplate: String? = null
) {
val author: String?
get() = authors.firstOrNull()?.name
val bestAcquisition: OpdsAcquisition?
get() = acquisitions.maxByOrNull { it.priority }
val isAcquisition: Boolean
get() = acquisitions.isNotEmpty()
val isNavigation: Boolean
get() = navigationUrl != null && acquisitions.isEmpty()
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
}

View file

@ -0,0 +1,486 @@
// OpdsParser.kt
package com.aryan.reader.opds
import android.util.Xml
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.InputStream
import java.util.UUID
class OpdsParser {
fun parse(bodyString: String, baseUrl: String): OpdsFeed {
val trimmed = bodyString.trimStart()
return if (trimmed.startsWith("{")) {
Timber.tag("OpdsDebug").d("Detected OPDS 2.0 (JSON) feed")
parseOpds2(trimmed, baseUrl)
} else {
Timber.tag("OpdsDebug").d("Detected OPDS 1.x (XML) feed")
parseOpds1(trimmed.byteInputStream(), baseUrl)
}
}
// --- OPDS 2.0 (JSON) Parsing ---
private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
val root = JSONObject(jsonString)
val metadata = root.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "OPDS 2.0 Feed"
var nextUrl: String? = null
var searchUrl: String? = null
val facets = mutableListOf<OpdsFacet>()
// Root Links
val links = root.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val relArray = link.optJSONArray("rel")
val rels = mutableListOf<String>()
if (relArray != null) {
for (j in 0 until relArray.length()) rels.add(relArray.getString(j))
} else if (link.has("rel")) {
val rel = link.optString("rel")
if (rel.isNotBlank()) rels.add(rel)
}
val href = link.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (rels.contains("next")) {
nextUrl = resolvedHref
} else if (rels.contains("search")) {
searchUrl = resolvedHref
}
}
}
}
// Facets
val facetsArray = root.optJSONArray("facets")
if (facetsArray != null) {
for (i in 0 until facetsArray.length()) {
val facetObj = facetsArray.getJSONObject(i)
val group = facetObj.optJSONObject("metadata")?.optString("title") ?: "Filter"
val facetLinks = facetObj.optJSONArray("links")
if (facetLinks != null) {
for (j in 0 until facetLinks.length()) {
val link = facetLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val titleFacet = link.optString("title", "Facet")
val properties = link.optJSONObject("properties")
val active = properties?.optBoolean("active", false) ?: false
facets.add(OpdsFacet(titleFacet, group, resolveUrl(baseUrl, href), active))
}
}
}
}
}
val entries = mutableListOf<OpdsEntry>()
// Publications
val publications = root.optJSONArray("publications")
if (publications != null) {
for (i in 0 until publications.length()) {
entries.add(parseOpds2Publication(publications.getJSONObject(i), baseUrl))
}
}
// Navigation
val navigation = root.optJSONArray("navigation")
if (navigation != null) {
for (i in 0 until navigation.length()) {
entries.add(parseOpds2Navigation(navigation.getJSONObject(i), baseUrl))
}
}
// Groups (Collections containing sub-navigation or sub-publications)
val groups = root.optJSONArray("groups")
if (groups != null) {
for (i in 0 until groups.length()) {
val group = groups.getJSONObject(i)
val groupTitle = group.optJSONObject("metadata")?.optString("title") ?: ""
val groupNav = group.optJSONArray("navigation")
if (groupNav != null) {
for (j in 0 until groupNav.length()) {
entries.add(parseOpds2Navigation(groupNav.getJSONObject(j), baseUrl))
}
}
val groupPubs = group.optJSONArray("publications")
if (groupPubs != null) {
for (j in 0 until groupPubs.length()) {
entries.add(parseOpds2Publication(groupPubs.getJSONObject(j), baseUrl))
}
}
val groupLinks = group.optJSONArray("links")
if (groupLinks != null) {
for (j in 0 until groupLinks.length()) {
val link = groupLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val linkTitle = link.optString("title", groupTitle)
entries.add(OpdsEntry(
id = href,
title = linkTitle,
summary = null,
authors = emptyList(),
coverUrl = null,
acquisitions = emptyList(),
navigationUrl = resolveUrl(baseUrl, href)
))
}
}
}
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun parseOpds2Publication(pub: JSONObject, baseUrl: String): OpdsEntry {
val metadata = pub.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "Unknown Title"
val id = metadata?.optString("identifier") ?: pub.optString("id", UUID.randomUUID().toString())
val summary = metadata?.optString("description") ?: metadata?.optString("summary")
val language = metadata?.optString("language")
val publisher = metadata?.optString("publisher")
val published = metadata?.optString("published")
val authors = mutableListOf<OpdsAuthor>()
val authorObj = metadata?.opt("author")
if (authorObj is String) {
authors.add(OpdsAuthor(authorObj, null))
} else if (authorObj is JSONArray) {
for (i in 0 until authorObj.length()) {
val item = authorObj.get(i)
if (item is String) authors.add(OpdsAuthor(item, null))
else if (item is JSONObject) {
val name = item.optString("name")
var uri: String? = null
val links = item.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
}
} else if (authorObj is JSONObject) {
val name = authorObj.optString("name")
var uri: String? = null
val links = authorObj.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
val categories = mutableListOf<String>()
when (val subjectObj = metadata?.opt("subject")) {
is String -> categories.add(subjectObj)
is JSONArray -> {
for (i in 0 until subjectObj.length()) {
val subj = subjectObj.get(i)
if (subj is String) categories.add(subj)
else if (subj is JSONObject) categories.add(subj.optString("name"))
}
}
is JSONObject -> {
categories.add(subjectObj.optString("name"))
}
}
var series: String? = null
var seriesIndex: String? = null
val belongsTo = metadata?.optJSONObject("belongsTo")
if (belongsTo != null) {
val seriesObj = belongsTo.opt("series")
if (seriesObj is String) {
series = seriesObj
} else if (seriesObj is JSONObject) {
series = seriesObj.optString("name")
if (seriesObj.has("position")) {
seriesIndex = seriesObj.optDouble("position").toString().removeSuffix(".0")
}
} else if (seriesObj is JSONArray && seriesObj.length() > 0) {
val firstSeries = seriesObj.get(0)
if (firstSeries is String) {
series = firstSeries
} else if (firstSeries is JSONObject) {
series = firstSeries.optString("name")
if (firstSeries.has("position")) {
seriesIndex = firstSeries.optDouble("position").toString().removeSuffix(".0")
}
}
}
}
var coverUrl: String? = null
val images = pub.optJSONArray("images")
if (images != null && images.length() > 0) {
for (i in 0 until images.length()) {
val image = images.getJSONObject(i)
val href = image.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (coverUrl == null) coverUrl = resolvedHref
val rels = image.opt("rel")
var isCover = false
if (rels is String && rels == "cover") isCover = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "cover") isCover = true
}
if (isCover) {
coverUrl = resolvedHref
break
}
}
}
}
val acquisitions = mutableListOf<OpdsAcquisition>()
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val links = pub.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val href = link.optString("href")
if (href.isNotEmpty()) {
val rels = link.opt("rel")
var isStream = false
if (rels is String && rels == "http://vaemendis.net/opds-pse/stream") isStream = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "http://vaemendis.net/opds-pse/stream") isStream = true
}
if (isStream) {
pseUrlTemplate = resolveUrl(baseUrl, href)
val properties = link.optJSONObject("properties")
pseCount = properties?.optInt("numberOfItems")?.takeIf { it > 0 }
}
var isAcquisition = false
if (rels is String && rels.contains("acquisition")) isAcquisition = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j).contains("acquisition")) isAcquisition = true
}
if (isAcquisition) {
val type = link.optString("type") ?: ""
acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type))
}
}
}
}
return OpdsEntry(
id = id, title = title, summary = summary, authors = authors,
coverUrl = coverUrl, acquisitions = acquisitions,
navigationUrl = null, publisher = publisher, published = published,
language = language, series = series, seriesIndex = seriesIndex, categories = categories,
pseCount = pseCount, pseUrlTemplate = pseUrlTemplate
)
}
private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry {
val title = nav.optString("title", "Unknown")
val href = nav.optString("href")
val summary = nav.optString("description", null)
val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null
return OpdsEntry(
id = href, title = title, summary = summary, authors = emptyList(),
coverUrl = null, acquisitions = emptyList(),
navigationUrl = navigationUrl
)
}
// --- OPDS 1.x (XML) Parsing ---
private fun parseOpds1(inputStream: InputStream, baseUrl: String): OpdsFeed {
return inputStream.use {
val parser: XmlPullParser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(it, null)
parser.nextTag()
Timber.tag("OpdsDebug").d($$"Parser started at root tag: <${parser.name}>")
readFeed(parser, baseUrl)
}
}
private fun readFeed(parser: XmlPullParser, baseUrl: String): OpdsFeed {
var title = ""
var nextUrl: String? = null
var searchUrl: String? = null
val entries = mutableListOf<OpdsEntry>()
val facets = mutableListOf<OpdsFacet>()
parser.require(XmlPullParser.START_TAG, null, "feed")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"title" -> title = readText(parser)
"entry" -> entries.add(readEntry(parser, baseUrl))
"link" -> {
val rel = parser.getAttributeValue(null, "rel")
val href = parser.getAttributeValue(null, "href")
val linkTitle = parser.getAttributeValue(null, "title")
val facetGroup = parser.getAttributeValue(null, "opds:facetGroup") ?: "Filter"
val activeFacet = parser.getAttributeValue(null, "opds:activeFacet") == "true"
if (rel == "next") {
nextUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "search") {
searchUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "facet" || rel == "http://opds-spec.org/facet") {
if (href != null && linkTitle != null) {
facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun readEntry(parser: XmlPullParser, baseUrl: String): OpdsEntry {
parser.require(XmlPullParser.START_TAG, null, "entry")
var id = ""; var title = ""; var summary: String? = null
var coverUrl: String? = null; var navigationUrl: String? = null
var publisher: String? = null; var published: String? = null; var language: String? = null
var series: String? = null; var seriesIndex: String? = null
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val authors = mutableListOf<OpdsAuthor>()
val categories = mutableListOf<String>()
val acquisitions = mutableListOf<OpdsAcquisition>()
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (val tagName = parser.name.substringAfter(":")) {
"id" -> id = readText(parser)
"title" -> title = readText(parser)
"summary", "content" -> summary = readText(parser)
"author" -> authors.add(readAuthor(parser, baseUrl))
"publisher" -> publisher = readText(parser)
"language" -> language = language ?: readText(parser)
"issued", "published", "updated" -> {
val date = readText(parser)
if (published == null || tagName != "updated") published = date
}
"category" -> {
val label = parser.getAttributeValue(null, "label")
val term = parser.getAttributeValue(null, "term")
val cat = label ?: term
if (!cat.isNullOrBlank()) categories.add(cat)
skip(parser)
}
"meta" -> {
val property = parser.getAttributeValue(null, "property") ?: parser.getAttributeValue(null, "name")
val content = parser.getAttributeValue(null, "content")
val textContent = readText(parser)
if (property == "calibre:series") series = content ?: textContent.takeIf { it.isNotBlank() }
else if (property == "calibre:series_index") seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
}
"link" -> {
val rel = parser.getAttributeValue(null, "rel") ?: ""
val href = parser.getAttributeValue(null, "href") ?: ""
val type = parser.getAttributeValue(null, "type") ?: ""
val linkTitle = parser.getAttributeValue(null, "title")
if (rel == "http://vaemendis.net/opds-pse/stream") {
pseUrlTemplate = resolveUrl(baseUrl, href)
val countStr = parser.getAttributeValue(null, "pse:count")
pseCount = countStr?.toIntOrNull()
}
if (rel == "http://calibre-ebook.com/opds/series") {
if (series == null) series = linkTitle
}
if (href.isNotEmpty()) {
val absoluteUrl = resolveUrl(baseUrl, href)
if (rel.contains("http://opds-spec.org/image")) {
if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl
} else if (rel.contains("http://opds-spec.org/acquisition")) {
acquisitions.add(OpdsAcquisition(absoluteUrl, type))
} else if (type.contains("profile=opds-catalog") || type.contains("application/atom+xml")) {
if (navigationUrl == null) navigationUrl = absoluteUrl
} else if (rel == "subsection" || rel == "collection" || rel == "start") {
if (navigationUrl == null) navigationUrl = absoluteUrl
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsEntry(id, title, summary, authors, coverUrl, acquisitions, navigationUrl, publisher, published, language, series, seriesIndex, categories, pseCount, pseUrlTemplate)
}
private fun readAuthor(parser: XmlPullParser, baseUrl: String): OpdsAuthor {
var name = ""
var uri: String? = null
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"name" -> name = readText(parser)
"uri" -> uri = resolveUrl(baseUrl, readText(parser))
else -> skip(parser)
}
}
return OpdsAuthor(name, uri)
}
private fun readText(parser: XmlPullParser): String {
val result = StringBuilder()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.TEXT, XmlPullParser.CDSECT, XmlPullParser.ENTITY_REF -> {
result.append(parser.text)
}
XmlPullParser.START_TAG -> depth++
XmlPullParser.END_TAG -> depth--
}
}
return result.toString().trim()
}
private fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) throw java.lang.IllegalStateException()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
}

View file

@ -0,0 +1,265 @@
package com.aryan.reader.opds
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.security.MessageDigest
import java.util.UUID
class OpdsRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
private val parser = OpdsParser()
companion object {
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
val sharedHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
}
}
private val httpClient = sharedHttpClient
fun getCatalogs(): List<OpdsCatalog> {
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
val catalogs = mutableListOf<OpdsCatalog>()
if (jsonString != null) {
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
catalogs.add(
OpdsCatalog(
id = obj.getString("id"),
title = obj.getString("title"),
url = obj.getString("url"),
isDefault = obj.optBoolean("isDefault", false),
username = obj.optString("username", "").takeIf { it.isNotBlank() },
password = obj.optString("password", "").takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
if (catalogs.isEmpty()) {
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
saveCatalogs(catalogs)
}
return catalogs
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(openSearchUrl).build()
val response = httpClient.newCall(request).execute()
val body = response.body?.string() ?: return@withContext null
val parser = android.util.Xml.newPullParser()
parser.setFeature(org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(body.byteInputStream(), null)
var eventType = parser.eventType
while (eventType != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
if (eventType == org.xmlpull.v1.XmlPullParser.START_TAG && parser.name.equals("Url", ignoreCase = true)) {
val type = parser.getAttributeValue(null, "type")
if (type != null && (type.contains("atom+xml") || type.contains("opds+xml"))) {
val template = parser.getAttributeValue(null, "template")
if (template != null) {
val resolvedTemplate = resolveUrl(openSearchUrl, template)
Timber.tag("OpdsDebug").d("Resolved search template: $resolvedTemplate")
return@withContext resolvedTemplate
}
}
}
eventType = parser.next()
}
null
} catch (e: Exception) {
Timber.e(e, "Failed to fetch OpenSearch template")
null
}
}
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
val current = getCatalogs().toMutableList()
current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password))
saveCatalogs(current)
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
val current = getCatalogs().toMutableList()
val index = current.indexOfFirst { it.id == id }
if (index != -1 && !current[index].isDefault) {
current[index] = current[index].copy(
title = title.trim(),
url = url.trim(),
username = username?.trim().takeIf { !it.isNullOrBlank() },
password = password?.trim().takeIf { !it.isNullOrBlank() }
)
saveCatalogs(current)
}
}
fun removeCatalog(id: String) {
val current = getCatalogs().toMutableList()
val toRemove = current.find { it.id == id }
if (toRemove?.isDefault == true) {
return
}
current.removeAll { it.id == id }
saveCatalogs(current)
}
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
val jsonArray = JSONArray()
catalogs.forEach { catalog ->
val obj = JSONObject()
obj.put("id", catalog.id)
obj.put("title", catalog.title)
obj.put("url", catalog.url)
obj.put("isDefault", catalog.isDefault)
if (catalog.username != null) obj.put("username", catalog.username)
if (catalog.password != null) obj.put("password", catalog.password)
jsonArray.put(obj)
}
prefs.edit { putString(KEY_CATALOGS_JSON, jsonArray.toString()) }
}
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
return httpClient.newBuilder()
.authenticator(OpdsAuthenticator(username, password))
.build()
}
class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
private var cnonceCount = 0
override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
if (response.request.header("Authorization") != null) {
return null
}
val wwwAuth = response.header("WWW-Authenticate") ?: return null
if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
val credential = okhttp3.Credentials.basic(user, pass)
return response.request.newBuilder().header("Authorization", credential).build()
}
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
val realm = extractParam(wwwAuth, "realm") ?: ""
val nonce = extractParam(wwwAuth, "nonce") ?: ""
val qop = extractParam(wwwAuth, "qop")
val opaque = extractParam(wwwAuth, "opaque")
cnonceCount++
val nc = String.format("%08x", cnonceCount)
val cnonce = UUID.randomUUID().toString().replace("-", "")
val url = response.request.url
val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
val ha1 = md5("$user:$realm:$pass")
val ha2 = md5("${response.request.method}:$uri")
val responseHash = if (qop != null) {
md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
} else {
md5("$ha1:$nonce:$ha2")
}
val digestHeader = buildString {
append("Digest username=\"$user\", ")
append("realm=\"$realm\", ")
append("nonce=\"$nonce\", ")
append("uri=\"$uri\", ")
append("response=\"$responseHash\"")
if (qop != null) {
append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
}
if (opaque != null) {
append(", opaque=\"$opaque\"")
}
}
return response.request.newBuilder()
.header("Authorization", digestHeader)
.build()
}
return null
}
private fun extractParam(header: String, param: String): String? {
val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
return match?.groupValues?.get(1)
}
private fun md5(input: String): String {
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed> = withContext(Dispatchers.IO) {
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
try {
val client = getAuthenticatedClient(username, password)
val request = Request.Builder()
.url(url.trim())
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
Timber.tag("OpdsDebug").d("Executing network call...")
val response = client.newCall(request).execute()
Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
if (!response.isSuccessful) {
val errorMsg = "HTTP ${response.code}: ${response.message}"
Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
return@withContext Result.failure(Exception(errorMsg))
}
val bodyString = response.body?.string()
if (bodyString.isNullOrBlank()) {
return@withContext Result.failure(Exception("Empty response body"))
}
val feed = parser.parse(bodyString, url)
Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
Result.success(feed)
} catch (e: Exception) {
Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
Result.failure(e)
}
}
}

View file

@ -0,0 +1,224 @@
package com.aryan.reader.opds
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import timber.log.Timber
import java.io.File
data class OpdsScreenState(
val catalogs: List<OpdsCatalog> = emptyList(),
val currentCatalog: OpdsCatalog? = null,
val currentFeed: OpdsFeed? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isViewingCatalog: Boolean = false,
val searchUrlTemplate: String? = null
)
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application)
private val _uiState = MutableStateFlow(OpdsScreenState())
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
private val urlStack = mutableListOf<String>()
private val _downloadingEntries = MutableStateFlow<Set<String>>(emptySet())
val downloadingEntries: StateFlow<Set<String>> = _downloadingEntries.asStateFlow()
private fun fetchUrl(url: String, isPagination: Boolean = false) {
viewModelScope.launch {
val catalog = _uiState.value.currentCatalog
_uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) }
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
result.onSuccess { newFeed ->
val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate
if (!isPagination) {
if (urlStack.isEmpty() || urlStack.last() != url) {
urlStack.add(url)
}
_uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) }
} else {
_uiState.update { state ->
val currentEntries = state.currentFeed?.entries ?: emptyList()
state.copy(
isLoading = false,
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
searchUrlTemplate = template
)
}
}
}.onFailure { e ->
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load feed: ${e.message}") }
}
}
}
fun loadNextPage() {
val nextUrl = _uiState.value.currentFeed?.nextUrl
if (nextUrl != null && !_uiState.value.isLoading) {
fetchUrl(nextUrl, isPagination = true)
}
}
data class DownloadState(val isDownloading: Boolean, val progress: Float? = null)
private val _downloadingState = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
val downloadingState: StateFlow<Map<String, DownloadState>> = _downloadingState.asStateFlow()
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
val downloadUrl = acquisition.url
val catalog = _uiState.value.currentCatalog
viewModelScope.launch(Dispatchers.IO) {
_downloadingState.update { it + (entry.id to DownloadState(true, 0f)) }
try {
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
val request = Request.Builder().url(downloadUrl).build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body ?: throw Exception("Empty body")
val contentLength = body.contentLength()
val ext = when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"TXT" -> ".txt"
else -> ".epub"
}
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
val input = body.byteStream()
val output = tempFile.outputStream()
val buffer = ByteArray(8 * 1024)
var bytesRead: Int
var totalRead = 0L
var lastProgressUpdate = System.currentTimeMillis()
input.use { inp ->
output.use { out ->
while (inp.read(buffer).also { bytesRead = it } != -1) {
out.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (contentLength > 0) {
val now = System.currentTimeMillis()
// Throttle UI updates to 4-5 fps
if (now - lastProgressUpdate > 200) {
val progress = totalRead.toFloat() / contentLength.toFloat()
_downloadingState.update { it + (entry.id to DownloadState(true, progress)) }
lastProgressUpdate = now
}
}
}
}
}
withContext(Dispatchers.Main) {
onDownloaded(Uri.fromFile(tempFile))
}
} else {
Timber.e("Download failed: ${response.code}")
_uiState.update { it.copy(errorMessage = "Download failed: ${response.message}") }
}
} catch (e: Exception) {
Timber.e(e, "Download error")
_uiState.update { it.copy(errorMessage = "Download error: ${e.message}") }
} finally {
_downloadingState.update { it - entry.id }
}
}
}
init {
loadCatalogs()
}
private fun loadCatalogs() {
_uiState.update { it.copy(catalogs = repository.getCatalogs()) }
}
fun addCatalog(title: String, url: String, username: String?, password: String?) {
repository.addCatalog(title, url, username, password)
loadCatalogs()
}
fun removeCatalog(id: String) {
repository.removeCatalog(id)
loadCatalogs()
}
fun openCatalog(catalog: OpdsCatalog) {
urlStack.clear()
_uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) }
fetchUrl(catalog.url)
}
fun openFeedUrl(url: String) {
fetchUrl(url)
}
fun navigateBack(): Boolean {
if (urlStack.size > 1) {
urlStack.removeAt(urlStack.lastIndex)
val previousUrl = urlStack.last()
urlStack.removeAt(urlStack.lastIndex)
fetchUrl(previousUrl)
return true
} else {
urlStack.clear()
_uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) }
return false
}
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
repository.updateCatalog(id, title, url, username, password)
loadCatalogs()
}
fun search(query: String) {
val searchLink = _uiState.value.searchUrlTemplate ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val template = if (!searchLink.contains("{searchTerms}")) {
repository.getSearchTemplate(searchLink) ?: searchLink
} else {
searchLink
}
val finalUrl = if (template.contains("{searchTerms}")) {
template.replace("{searchTerms}", Uri.encode(query))
} else {
val separator = if (template.contains("?")) "&" else "?"
"$template${separator}query=${Uri.encode(query)}"
}
openFeedUrl(finalUrl)
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
}

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

View file

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.141.181</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.31.49</domain>
</domain-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>