book-reader/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
Aryan d7a9cae9e1
v1.0.47 (#279)
* Add performance and stylus debugging logs

* Refactor and decouple UI models from `MainViewModel`

* Refactor library state management and projection logic

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Implement desktop shell using Compose Multiplatform

* Introduce ReaderEngine and enhance EPUB reader features in windows app

* Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support.

* Implement PDF rendering and text extraction for desktop using Pdfium

* Add `NonReaderScreens.kt` and UI dependencies

* Refactor and centralize library state management and models to improve cross-platform consistency

* Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing

* Implement PDF annotation system and enhanced zoom controls for the desktop viewer

* Implement WebView-based EPUB rendering for desktop using CEF and embedded resources

* Optimize UI state projection, navigation state handling, and main screen pager performance

* Implement Bring Your Own Key (BYOK) support for AI features in OSS version

* Support Gemini-based Cloud TTS with BYOK support for OSS builds

* Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling.

* crash fixes

* Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6

* Implement dynamic bottom padding for the page info bar to account for device rounded corners

* Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar`

* Optimize PDF tiling performance and refine pan-and-fling gesture handling

* Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers

* Updated UI for customize toolbar

* Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls

* restructure PDF viewer UI component hierarchy to fix verifier crash

* Implement separate text dimming factors for light and dark themes

* Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers

* Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning

* Centralize file type resolution logic and implement HTML sanitization during import

* Introduce vertical margin customization and configurable progress bar positioning

* texture support in epub reader

* Enhance TTS session management, progress tracking, and diagnostic logging

* Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic.

* Refine TTS page mapping for PDF and overhaul TTS control UI

* Implement natural session completion logic in `TtsPlaybackManager` for cloud tts

* Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen`

* Refine TTS playback continuity across PDF pages and improve state management for session transitions

* Implement global texture transparency and enhance textured theme support across PDF and EPUB readers.

* Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode

* Add Support Project screen

* Optimize library performance via projection caching, batch database updates, and scoped folder synchronization.

* Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic

* Bump version to 1.0.47 (51)
2026-05-04 21:55:38 +05:30

256 lines
9.7 KiB
Kotlin

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 com.aryan.reader.resolveFileExtensionSuffixFromName
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.Response
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 = resolveOpdsDownloadExtension(acquisition, response)
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 }
}
}
}
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
val candidates = listOfNotNull(
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
Uri.parse(acquisition.url).lastPathSegment
)
candidates.forEach { candidate ->
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
private fun extractContentDispositionFilename(contentDisposition: String): String? {
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
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) }
}
}