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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Folder
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
import androidx.compose.material.icons.filled.FolderSpecial
|
||||||
import androidx.compose.material.icons.filled.FormatListNumbered
|
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) {
|
if (isPinned) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
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)
|
private val _reviewRequestEvent = Channel<Unit>(Channel.BUFFERED)
|
||||||
val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow()
|
val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow()
|
||||||
private var hasRequestedReviewInThisSession = false
|
private var hasRequestedReviewInThisSession = false
|
||||||
|
|
@ -1169,6 +1209,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private fun uploadSingleBookMetadata(book: RecentFileItem) {
|
private fun uploadSingleBookMetadata(book: RecentFileItem) {
|
||||||
if (!uiState.value.isSyncEnabled) return
|
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) {
|
if (book.sourceFolderUri != null) {
|
||||||
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
|
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
|
||||||
return
|
return
|
||||||
|
|
@ -2027,11 +2072,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
val localBooks = withContext(Dispatchers.IO) {
|
val localBooks = withContext(Dispatchers.IO) {
|
||||||
val allFiles = recentFilesRepository.getAllFilesForSync()
|
val allFiles = recentFilesRepository.getAllFilesForSync()
|
||||||
if (_internalState.value.isFolderSyncEnabled) {
|
val filtered = if (_internalState.value.isFolderSyncEnabled) {
|
||||||
allFiles
|
allFiles
|
||||||
} else {
|
} else {
|
||||||
allFiles.filter { it.sourceFolderUri == null }
|
allFiles.filter { it.sourceFolderUri == null }
|
||||||
}
|
}
|
||||||
|
filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
|
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
|
||||||
|
|
@ -2424,7 +2470,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (coverBitmap != null) {
|
if (coverBitmap != null) {
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
|
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
|
var cacheFile: File? = null
|
||||||
try {
|
try {
|
||||||
cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
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 oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
|
||||||
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
|
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
|
||||||
|
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
|
||||||
|
|
||||||
cacheDir.listFiles()?.forEach { file ->
|
cacheDir.listFiles()?.forEach { file ->
|
||||||
val name = file.name
|
val name = file.name
|
||||||
|
|
@ -2538,6 +2585,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val deleted = file.deleteRecursively()
|
val deleted = file.deleteRecursively()
|
||||||
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
|
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")
|
val legacyExtractedDir = File(cacheDir, "extracted_epubs")
|
||||||
|
|
@ -2837,6 +2890,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
|
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
|
||||||
|
|
||||||
|
if (uri.scheme != "opds-pse") {
|
||||||
try {
|
try {
|
||||||
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
|
val cursor = appContext.contentResolver.query(uri, null, null, null, null)
|
||||||
cursor?.use {
|
cursor?.use {
|
||||||
|
|
@ -2852,6 +2906,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
|
Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
|
|
|
||||||
|
|
@ -335,8 +335,11 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
||||||
val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
|
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 {
|
try {
|
||||||
val uri = item.uriString.toUri()
|
val uri = item.uriString.toUri()
|
||||||
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {
|
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 {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri")
|
Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri")
|
||||||
|
|
||||||
|
if (pdfUri.scheme != "opds-pse") {
|
||||||
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
|
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
|
||||||
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
||||||
|
}
|
||||||
|
|
||||||
val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
doc.close()
|
doc.close()
|
||||||
currentPfdOpened.close()
|
currentPfdOpened?.close()
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ import java.io.File
|
||||||
import me.zhanghai.android.libarchive.Archive
|
import me.zhanghai.android.libarchive.Archive
|
||||||
import me.zhanghai.android.libarchive.ArchiveEntry
|
import me.zhanghai.android.libarchive.ArchiveEntry
|
||||||
import me.zhanghai.android.libarchive.ArchiveException
|
import me.zhanghai.android.libarchive.ArchiveException
|
||||||
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
|
||||||
interface ReaderDocument : AutoCloseable {
|
interface ReaderDocument : AutoCloseable {
|
||||||
suspend fun getPageCount(): Int
|
suspend fun getPageCount(): Int
|
||||||
|
|
@ -68,6 +70,13 @@ interface ReaderWebLinks : AutoCloseable {
|
||||||
|
|
||||||
object DocumentFactory {
|
object DocumentFactory {
|
||||||
suspend fun loadDocument(context: Context, uri: Uri, type: FileType, password: String?, pdfiumCore: PdfiumCoreKt): ReaderDocument {
|
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) {
|
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -117,13 +126,36 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getNativePointer(): Long {
|
override fun getNativePointer(): Long {
|
||||||
return try {
|
return extractNativePointer(pdfPage)
|
||||||
val field = pdfPage.javaClass.getDeclaredField("mNativePagePtr")
|
|
||||||
field.isAccessible = true
|
|
||||||
field.get(pdfPage) as? Long ?: 0L
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() }
|
override fun close() { pdfPage.close() }
|
||||||
|
|
@ -389,3 +421,92 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
|
||||||
decoder?.recycle()
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">192.168.141.181</domain>
|
<trust-anchors>
|
||||||
</domain-config>
|
<certificates src="system" />
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
</trust-anchors>
|
||||||
<domain includeSubdomains="true">192.168.31.49</domain>
|
</base-config>
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue