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:
parent
355664fbcc
commit
15264a31ae
12 changed files with 2208 additions and 33 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
93
app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
Normal file
93
app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
Normal 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
|
||||
}
|
||||
486
app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
Normal file
486
app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
265
app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt
Normal file
265
app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
224
app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
Normal file
224
app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue