From fef33afe69fc4f135accfb96c8f99d8f3c060f48 Mon Sep 17 00:00:00 2001
From: Atte149
Date: Wed, 24 Jun 2026 16:41:38 +0300
Subject: [PATCH] =?UTF-8?q?feat(backend):=20Phase=202=20complete=20?=
=?UTF-8?q?=E2=80=94=20bookshelf-api=20+=20ABS,=20remove=20OPDS/Gutenberg/?=
=?UTF-8?q?LocalFolder/CloudSync?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 2.4: download/open ebooks from server
- BookshelfViewModel now extends AndroidViewModel, downloads books via BookshelfApiRepository.downloadEbook
- Saves to cache dir, emits Uri via SharedFlow, opens in reader via MainViewModel.onFileSelected
- Download progress indicator in BookshelfLibraryScreen item cards
Phase 2.5: remove legacy backends
- Delete reader/opds/ package (OpdsModels, OpdsParser, OpdsRepository, OpdsViewModel)
- Delete CloudSyncTrace.kt and FolderSyncWorker.kt
- Delete OpdsTab, rememberOpdsCoverImageLoader, OpdsCatalogCard, OpdsNavigationCard, OpdsBookCard, OpdsBookDetailsSheet from LibraryScreen
- Delete FolderSyncScreen, FolderCard, EditFolderFiltersDialog from LibraryScreen
- Remove 'Catalogs' tab from LibraryScreen tabTitles
- Add CloudSyncTraceStub.kt with no-op stubs for logCloudSyncTrace/Error/Annotation variants
- Add FolderSyncWorkerStub.kt as no-op CoroutineWorker with constants
- Fix OpdsStreamDocumentWrapper to use plain OkHttpClient
- ':app:assembleOssDebug' passes (APK ~80 MB).
---
.../bookreader/reader/AppNavigation.kt | 7 +
.../bookreader/reader/CloudSyncTrace.kt | 70 -
.../bookreader/reader/CloudSyncTraceStub.kt | 37 +
.../bookreader/reader/FolderSyncWorker.kt | 773 ----------
.../bookreader/reader/FolderSyncWorkerStub.kt | 28 +
.../bookreader/reader/LibraryScreen.kt | 1322 +----------------
.../bookreader/reader/opds/OpdsModels.kt | 10 -
.../bookreader/reader/opds/OpdsParser.kt | 3 -
.../bookreader/reader/opds/OpdsRepository.kt | 214 ---
.../bookreader/reader/opds/OpdsViewModel.kt | 172 ---
.../reader/pdf/UniversalDocument.kt | 28 +-
.../bookshelf/BookshelfLibraryScreen.kt | 21 +-
.../bookshelf/BookshelfViewModel.kt | 69 +-
13 files changed, 163 insertions(+), 2591 deletions(-)
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
create mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
create mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
delete mode 100644 app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
index 9fd3842..8429f96 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/AppNavigation.kt
@@ -435,10 +435,17 @@ fun AppNavigation(
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
+ LaunchedEffect(bookshelfViewModel) {
+ bookshelfViewModel.downloadedFile.collect { uri ->
+ Timber.d("Downloaded book $uri, opening in reader")
+ viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false)
+ }
+ }
BookshelfLibraryScreen(
viewModel = bookshelfViewModel,
onItemClick = { item ->
Timber.d("Bookshelf item selected: ${item.id}")
+ bookshelfViewModel.downloadBook(item)
},
onOpenSettings = {
navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
deleted file mode 100644
index 198c806..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTrace.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package org.dueattendant149.bookreader
-
-import android.util.Log
-import org.dueattendant149.bookreader.data.BookMetadata
-import org.dueattendant149.bookreader.data.RecentFileItem
-import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
-import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
-import timber.log.Timber
-
-internal const val CloudSyncTraceTag = "EpistemeCloudSync"
-internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
-
-internal fun logCloudSyncTrace(message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val text = message()
- Log.d(CloudSyncTraceTag, text)
- Timber.tag(CloudSyncTraceTag).d(text)
-}
-
-internal fun logCloudSyncError(error: Throwable, message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val text = message()
- Log.e(CloudSyncTraceTag, text, error)
- Timber.tag(CloudSyncTraceTag).e(error, text)
-}
-
-internal fun logCloudAnnotationSyncTrace(message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val text = message()
- Log.d(CloudAnnotationSyncTraceTag, text)
- Timber.tag(CloudAnnotationSyncTraceTag).d(text)
-}
-
-internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val text = message()
- Log.e(CloudAnnotationSyncTraceTag, text, error)
- Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
-}
-
-internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
- return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
- "contentTs=$fileContentModifiedTimestamp " +
- "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
- "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
- "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
-}
-
-internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
- return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
- "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
- "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
- "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
- "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
- "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
-}
-
-internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
- val value = this ?: return "null"
- return if (value.length <= maxLength) value else value.take(maxLength) + "..."
-}
-
-internal fun String?.cloudSyncAnnotationSummary(): String {
- val value = this?.trim() ?: return "null"
- return when {
- value.isEmpty() -> "blank"
- value == "[]" -> "empty"
- else -> "present(${value.length})"
- }
-}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
new file mode 100644
index 0000000..5cd4c5f
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/CloudSyncTraceStub.kt
@@ -0,0 +1,37 @@
+package org.dueattendant149.bookreader
+
+/**
+ * Stubs for removed CloudSyncTrace.kt. All calls are no-ops.
+ */
+
+internal inline fun logCloudSyncTrace(crossinline message: () -> String) {
+ // no-op: cloud sync removed
+}
+
+internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) {
+ // no-op: cloud sync removed
+}
+
+internal inline fun logCloudSyncError(crossinline message: () -> String) {
+ // no-op: cloud sync removed
+}
+
+internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) {
+ // no-op: cloud annotation sync removed
+}
+
+internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) {
+ // no-op: cloud annotation sync removed
+}
+
+internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = ""
+
+internal fun Any?.cloudSyncAnnotationSummary(): String = ""
+
+internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: ""
+
+internal fun String.cloudSyncPreview(): String = this
+
+internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen)
+
+internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: ""
\ No newline at end of file
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
deleted file mode 100644
index e4e67cd..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorker.kt
+++ /dev/null
@@ -1,773 +0,0 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-// FolderSyncWorker.kt
-package org.dueattendant149.bookreader
-
-import android.content.Context
-import timber.log.Timber
-import androidx.core.net.toUri
-import androidx.documentfile.provider.DocumentFile
-import androidx.work.CoroutineWorker
-import androidx.work.ExistingWorkPolicy
-import androidx.work.OneTimeWorkRequestBuilder
-import androidx.work.WorkerParameters
-import androidx.work.WorkManager
-import org.dueattendant149.bookreader.data.RecentFileItem
-import org.dueattendant149.bookreader.data.RecentFilesRepository
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import androidx.core.content.edit
-import org.dueattendant149.bookreader.data.LocalSyncUtils
-import org.dueattendant149.bookreader.data.FolderBookMetadata
-import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
-import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
-import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
-import org.dueattendant149.bookreader.shared.EpubBookmark
-import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
-import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
-import org.dueattendant149.bookreader.shared.ReaderLocator
-import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
-import org.dueattendant149.bookreader.shared.SharedReaderScreenState
-import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
-import java.io.File
-import android.provider.DocumentsContract
-
-class FolderSyncWorker(
- private val appContext: Context,
- workerParams: WorkerParameters
-) : CoroutineWorker(appContext, workerParams) {
-
- private val recentFilesRepository = RecentFilesRepository(appContext)
-
- companion object {
- const val WORK_NAME = "FolderSyncWorker"
- const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
- const val KEY_METADATA_ONLY = "key_metadata_only"
- const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
- private val syncMutex = Mutex()
- }
-
- override suspend fun doWork(): Result {
- val workerStart = ReaderPerfLog.nowNanos()
- val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
- val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
- val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
-
- val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
- val folders = SyncedFolderPrefs.decodeSyncedFolders(
- jsonString = jsonString,
- legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
- syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
- )
-
- if (folders.isEmpty()) {
- ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
- return Result.success()
- }
-
- val enabledFolders = folders.filter { it.localSyncEnabled }
- val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
- enabledFolders
- } else {
- enabledFolders.filter { it.uriString == targetFolderUri }
- }
-
- if (foldersToProcess.isEmpty()) {
- ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
- return Result.success()
- }
-
- ReaderPerfLog.d(
- "FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
- "target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
- )
-
- return withContext(Dispatchers.IO) {
- syncMutex.withLock {
- var allSuccess = true
-
- for (folderConfig in foldersToProcess) {
- val success = performSyncForFolder(folderConfig, isMetadataOnly)
- if (!success) allSuccess = false
- }
-
- if (jsonString != null) {
- try {
- val array = org.json.JSONArray(jsonString)
- val now = System.currentTimeMillis()
- val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
- for (i in 0 until array.length()) {
- val obj = array.getJSONObject(i)
- if (obj.optString("uri") in processedUris) {
- obj.put("lastScanTime", now)
- }
- }
- prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
- } catch (_: Exception) {}
- }
-
- val elapsed = ReaderPerfLog.elapsedMs(workerStart)
- ReaderPerfLog.i(
- "FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
- "folders=${foldersToProcess.size} elapsed=${elapsed}ms"
- )
-
- if (allSuccess) Result.success() else Result.failure()
- }
- }
- }
-
- private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
- val folderUriString = folderConfig.uriString
- val allowedFileTypes = folderConfig.allowedFileTypes
- if (folderUriString.isBlank()) return true
- val folderUri = folderUriString.toUri()
- val folderStart = ReaderPerfLog.nowNanos()
- var dirsScanned = 0
- var filesSeen = 0
- var supportedBooksSeen = 0
- var dbFlushes = 0
- var sidecarsImported = 0
- var stoppedForUnlinkedFolder = false
-
- try {
- if (!isFolderStillLinked(folderUriString)) {
- ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
- return true
- }
-
- try {
- appContext.contentResolver.takePersistableUriPermission(
- folderUri,
- android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
- )
- } catch (_: SecurityException) {
- return false
- }
-
- val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
- if (documentTree == null || !documentTree.isDirectory) {
- return false
- }
-
- ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
-
- val folderMetadataMap = ReaderPerfLog.measureSuspend(
- name = "FolderSync phase metadata-sidecars",
- minLogMs = 25L,
- details = { "metadataOnly=$metadataOnly" }
- ) {
- LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
- }
- ReaderPerfLog.d(
- "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
- )
-
- val existingFolderBooks = ReaderPerfLog.measureSuspend(
- name = "FolderSync phase load-existing-db",
- minLogMs = 25L
- ) {
- recentFilesRepository.getFilesBySourceFolder(folderUriString)
- }
- val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
-
- val scanResult = if (metadataOnly) {
- AndroidFolderScanResult()
- } else {
- ReaderPerfLog.measureSuspend(
- name = "FolderSync phase scan-folder",
- minLogMs = 25L
- ) {
- scanFolderFiles(
- folderUri = folderUri,
- folderUriString = folderUriString,
- allowedFileTypes = allowedFileTypes
- )
- }
- }
- dirsScanned = scanResult.dirsScanned
- filesSeen = scanResult.filesSeen
- supportedBooksSeen = scanResult.files.size
- stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
-
- if (isStopped || stoppedForUnlinkedFolder) {
- ReaderPerfLog.w(
- "FolderSync folder aborted before shared engine stopped=$isStopped " +
- "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
- )
- return true
- }
-
- val nowMillis = System.currentTimeMillis()
- val folder = SyncedFolder(
- uriString = folderUriString,
- name = documentTree.name ?: folderConfig.name,
- lastScanTime = nowMillis,
- allowedFileTypes = allowedFileTypes,
- localSyncEnabled = true
- )
- val sharedState = SharedReaderScreenState(
- rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
- syncedFolders = listOf(folder)
- )
- val syncResult = LocalFolderSyncEngine.syncFolder(
- state = sharedState,
- folder = folder,
- files = scanResult.files,
- remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
- nowMillis = nowMillis,
- metadataOnly = metadataOnly
- )
-
- if (syncResult.idMigrations.isNotEmpty()) {
- val preloadedSidecars = ReaderPerfLog.measureSuspend(
- name = "FolderSync phase migration-sidecars",
- minLogMs = 25L
- ) {
- LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
- }
- syncResult.idMigrations.forEach { (oldId, newId) ->
- Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
- migrateFolderBookId(
- folderUriString = folderUriString,
- oldId = oldId,
- newId = newId,
- folderMetadataMap = folderMetadataMap,
- preloadedSidecars = preloadedSidecars,
- existingItemsMap = existingItemsMap
- )
- }
- }
-
- if (!isFolderStillLinked(folderUriString)) {
- ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
- stoppedForUnlinkedFolder = true
- return true
- }
-
- val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
- val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
- val existing = existingItemsMap[book.id]
- val metadata = appliedMetadataFor(
- book = book,
- existing = existing,
- metadata = folderMetadataMap[book.id]
- )
- book.toFolderSyncRecentFileItem(
- existing = existing,
- appliedMetadata = metadata,
- scannedFile = scannedFilesById[book.id],
- nowMillis = nowMillis
- )
- }
- val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
-
- changedItems
- .filter { item ->
- val previous = existingItemsMap[item.bookId]
- previous != null && folderFileContentChanged(previous, item)
- }
- .forEach { item ->
- Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
- recentFilesRepository.clearLocalCachesForBook(item.bookId)
- }
-
- if (changedItems.isNotEmpty()) {
- recentFilesRepository.addRecentFiles(changedItems)
- dbFlushes++
- }
-
- if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
- Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
- recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
- }
-
- val booksForAnnotationSync = if (metadataOnly) {
- syncedItems
- } else {
- ReaderPerfLog.measureSuspend(
- name = "FolderSync phase load-post-scan-db",
- minLogMs = 25L
- ) {
- recentFilesRepository.getFilesBySourceFolder(folderUriString)
- }
- }
- sidecarsImported += importAnnotationSidecarsForBooks(
- folderUri = folderUri,
- folderUriString = folderUriString,
- books = booksForAnnotationSync,
- phase = if (metadataOnly) "metadata-only" else "post-scan"
- )
-
- val elapsed = ReaderPerfLog.elapsedMs(folderStart)
- ReaderPerfLog.i(
- "FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
- "dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
- "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
- "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
- "removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
- "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
- "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
- )
-
- if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
- if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
- ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
- val metaRequest = OneTimeWorkRequestBuilder()
- .setInputData(
- androidx.work.Data.Builder()
- .putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
- .build()
- )
- .build()
- WorkManager.getInstance(appContext).enqueueUniqueWork(
- MetadataExtractionWorker.WORK_NAME,
- ExistingWorkPolicy.REPLACE,
- metaRequest
- )
- } else {
- ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
- }
- }
-
- return true
-
- } catch (e: Exception) {
- Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
- return false
- }
- }
-
- private suspend fun importAnnotationSidecarsForBooks(
- folderUri: android.net.Uri,
- folderUriString: String,
- books: List,
- phase: String
- ): Int {
- if (books.isEmpty()) {
- ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
- return 0
- }
-
- val preloadedSidecars = ReaderPerfLog.measureSuspend(
- name = "FolderSync phase annotation-sidecars",
- minLogMs = 25L,
- details = { "phase=$phase" }
- ) {
- LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
- }
-
- ReaderPerfLog.d(
- "FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
- )
-
- if (preloadedSidecars.isEmpty()) return 0
-
- var imported = 0
- Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
- for (book in books) {
- if (isStopped || !isFolderStillLinked(folderUriString)) break
-
- val sidecarData = preloadedSidecars[book.bookId] ?: continue
- val (remoteTs, jsonPayload) = sidecarData
-
- val safeSlashBookId = book.bookId.replace("/", "_")
- val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
- val localFiles = listOf(
- File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
- File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
- File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
- File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
- File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
- )
- val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
-
- if (remoteTs > (localTs + 1000)) {
- Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
- recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
- imported++
- } else {
- Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
- }
- }
-
- ReaderPerfLog.i(
- "FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
- )
- return imported
- }
-
- private data class AndroidFolderScanResult(
- val files: List = emptyList(),
- val dirsScanned: Int = 0,
- val filesSeen: Int = 0,
- val stoppedForUnlinkedFolder: Boolean = false
- )
-
- private fun scanFolderFiles(
- folderUri: android.net.Uri,
- folderUriString: String,
- allowedFileTypes: Set
- ): AndroidFolderScanResult {
- Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
- val contentResolver = appContext.contentResolver
- val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
- val dirQueue = ArrayDeque()
- val scannedFiles = mutableListOf()
- var dirsScanned = 0
- var filesSeen = 0
- var stoppedForUnlinkedFolder = false
- dirQueue.add(rootDocId)
-
- val projection = arrayOf(
- DocumentsContract.Document.COLUMN_DOCUMENT_ID,
- DocumentsContract.Document.COLUMN_DISPLAY_NAME,
- DocumentsContract.Document.COLUMN_MIME_TYPE,
- DocumentsContract.Document.COLUMN_SIZE,
- DocumentsContract.Document.COLUMN_LAST_MODIFIED
- )
-
- while (dirQueue.isNotEmpty()) {
- if (isStopped) break
- if (!isFolderStillLinked(folderUriString)) {
- ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
- stoppedForUnlinkedFolder = true
- break
- }
- val currentDocId = dirQueue.removeFirst()
- dirsScanned++
- val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
-
- try {
- contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
- val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
- val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
- val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
- val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
- val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
-
- while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
- val docId = cursor.getString(idCol)
- val name = cursor.getString(nameCol) ?: ""
- val mimeType = cursor.getString(mimeCol)
- filesSeen++
-
- if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
- ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
- stoppedForUnlinkedFolder = true
- break
- }
-
- if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
- if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
- dirQueue.add(docId)
- }
- continue
- }
-
- val type = getFileType(name, mimeType)
- if (
- type == null ||
- type !in allowedFileTypes ||
- !isLocalFolderSyncEligibleFile(name, mimeType) ||
- name.endsWith(".json") ||
- name.startsWith(".")
- ) {
- continue
- }
-
- val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
- val relativePath = buildRelativePath(rootDocId, docId, name)
- scannedFiles += SharedFolderScannedFile(
- name = name,
- path = docUri.toString(),
- sourceFolder = folderUriString,
- relativePath = relativePath,
- type = type,
- size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
- lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
- )
- }
- }
- } catch (e: Exception) {
- Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
- }
-
- if (stoppedForUnlinkedFolder) break
- }
-
- return AndroidFolderScanResult(
- files = scannedFiles,
- dirsScanned = dirsScanned,
- filesSeen = filesSeen,
- stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
- )
- }
-
- private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
- return SharedBookItem(
- id = bookId,
- path = uriString,
- type = type,
- displayName = displayName,
- timestamp = lastModifiedTimestamp,
- coverImagePath = coverImagePath,
- title = title,
- author = author,
- description = description,
- originalTitle = originalTitle,
- originalAuthor = originalAuthor,
- originalSeriesName = originalSeriesName,
- originalSeriesIndex = originalSeriesIndex,
- originalDescription = originalDescription,
- progressPercentage = progressPercentage,
- isRecent = isRecent,
- fileSize = fileSize,
- fileContentModifiedTimestamp = fileContentModifiedTimestamp,
- sourceFolder = sourceFolderUri,
- folderTextMetadataParsed = folderTextMetadataParsed,
- seriesName = seriesName,
- seriesIndex = seriesIndex,
- lastPageIndex = lastPage,
- readerPosition = readerPositionOrNull(),
- readerBookmarks = parseReaderBookmarks(),
- readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
- readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
- )
- }
-
- private fun SharedBookItem.toFolderSyncRecentFileItem(
- existing: RecentFileItem?,
- appliedMetadata: FolderBookMetadata?,
- scannedFile: SharedFolderScannedFile?,
- nowMillis: Long
- ): RecentFileItem {
- val contentChanged = existing != null && folderFileContentChanged(existing, this)
- val localModifiedTimestamp = when {
- appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
- contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
- timestamp > 0L -> timestamp
- else -> nowMillis
- }
- val legacyPosition = readerPosition
- val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
- val mappedHighlightsJson = readerHighlights
- .takeIf { it.isNotEmpty() }
- ?.let(EpubAnnotationSerializer::highlightsToJson)
- val bookmarksJson = if (appliedMetadata != null || existing == null) {
- mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
- } else {
- existing.bookmarksJson
- }
- val highlightsJson = if (appliedMetadata != null || existing == null) {
- mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
- } else {
- existing.highlightsJson
- }
-
- return RecentFileItem(
- bookId = id,
- uriString = path,
- type = type,
- displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
- timestamp = when {
- existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
- appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
- else -> existing.timestamp
- },
- coverImagePath = coverImagePath,
- title = title,
- author = author,
- lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
- lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
- lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
- locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
- locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
- progressPercentage = progressPercentage,
- isRecent = isRecent,
- isAvailable = true,
- lastModifiedTimestamp = localModifiedTimestamp,
- isDeleted = false,
- bookmarksJson = bookmarksJson,
- sourceFolderUri = sourceFolder,
- isReflowPreferred = existing?.isReflowPreferred ?: false,
- customName = appliedMetadata?.customName ?: existing?.customName,
- highlightsJson = highlightsJson,
- fileSize = fileSize,
- fileContentModifiedTimestamp = fileContentModifiedTimestamp,
- seriesName = seriesName,
- seriesIndex = seriesIndex,
- description = description,
- originalTitle = originalTitle,
- originalAuthor = originalAuthor,
- originalSeriesName = originalSeriesName,
- originalSeriesIndex = originalSeriesIndex,
- originalDescription = originalDescription,
- folderTextMetadataParsed = folderTextMetadataParsed,
- folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
- readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
- tags = existing?.tags.orEmpty()
- )
- }
-
- private fun appliedMetadataFor(
- book: SharedBookItem,
- existing: RecentFileItem?,
- metadata: FolderBookMetadata?
- ): FolderBookMetadata? {
- if (metadata == null) return null
- val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
- return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
- }
-
- private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
- if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
- return ReaderLocator.fromLegacy(
- chapterIndex = lastChapterIndex,
- cfi = lastPositionCfi,
- pageIndex = lastPage
- )
- }
-
- private fun RecentFileItem.parseReaderBookmarks(): List {
- return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
- .mapIndexed { index, bookmark ->
- val locator = bookmark.locator.withFallbacks(
- chapterIndex = bookmark.chapterIndex,
- cfi = bookmark.cfi,
- pageIndex = bookmark.pageInChapter?.minus(1),
- textQuote = bookmark.snippet
- )
- val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
- ReaderBookmark(
- id = "bookmark_${bookId}_$index",
- pageIndex = pageIndex.coerceAtLeast(0),
- chapterTitle = bookmark.chapterTitle,
- preview = bookmark.snippet,
- locator = locator
- )
- }
- }
-
- private fun List.toAndroidBookmarksJson(bookId: String): String? {
- val bookmarks = mapIndexed { index, bookmark ->
- val locator = bookmark.locator
- val chapterIndex = locator.chapterIndex ?: 0
- val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
- EpubBookmark(
- cfi = cfi,
- chapterTitle = bookmark.chapterTitle,
- label = null,
- snippet = bookmark.preview,
- pageInChapter = bookmark.pageIndex + 1,
- totalPagesInChapter = null,
- chapterIndex = chapterIndex,
- locator = locator.withFallbacks(
- chapterIndex = chapterIndex,
- cfi = cfi,
- pageIndex = bookmark.pageIndex,
- textQuote = bookmark.preview
- )
- )
- }
- return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
- }
-
- private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
- val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
- val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
- previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
- return sizeChanged || modifiedChanged
- }
-
- private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
- val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
- val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
- previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
- return sizeChanged || modifiedChanged
- }
-
- private fun isFolderStillLinked(folderUriString: String): Boolean {
- val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- return SyncedFolderPrefs.isLocalSyncEnabled(
- jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
- legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
- folderUriString = folderUriString,
- syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
- )
- }
-
- private fun getFileType(name: String, mimeType: String?): FileType? {
- return resolveFileTypeFromMetadata(name, mimeType)
- }
-
- private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
- val rootPath = rootDocId.substringAfter(':', "")
- val docPath = docId.substringAfter(':', "")
- if (docPath.isBlank()) return fallbackName
- val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
- docPath.removePrefix(rootPath).trimStart('/')
- } else {
- docPath.substringAfterLast('/', fallbackName)
- }
- return relative.ifBlank { fallbackName }
- }
-
- private suspend fun migrateFolderBookId(
- folderUriString: String,
- oldId: String,
- newId: String,
- folderMetadataMap: MutableMap,
- preloadedSidecars: MutableMap>,
- existingItemsMap: MutableMap
- ) {
- if (oldId == newId) return
-
- recentFilesRepository.migrateBookIdLocally(oldId, newId)
-
- val oldMetadata = folderMetadataMap.remove(oldId)
- if (oldMetadata != null && newId !in folderMetadataMap) {
- val migratedMetadata = oldMetadata.copy(bookId = newId)
- LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
- folderMetadataMap[newId] = migratedMetadata
- }
-
- val oldSidecar = preloadedSidecars.remove(oldId)
- if (oldSidecar != null && newId !in preloadedSidecars) {
- LocalSyncUtils.saveAnnotationSidecar(
- context = appContext,
- sourceFolderUri = folderUriString.toUri(),
- bookId = newId,
- jsonPayload = oldSidecar.second,
- timestamp = oldSidecar.first
- )
- preloadedSidecars[newId] = oldSidecar
- }
-
- LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
-
- existingItemsMap.remove(oldId)
- recentFilesRepository.getFileByBookId(newId)?.let {
- existingItemsMap[newId] = it
- }
- }
-}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
new file mode 100644
index 0000000..0e2cd6a
--- /dev/null
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/FolderSyncWorkerStub.kt
@@ -0,0 +1,28 @@
+package org.dueattendant149.bookreader
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+
+/**
+ * Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker
+ * so existing WorkManager enqueue calls compile.
+ */
+const val KEY_METADATA_ONLY = "metadata_only"
+const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
+const val KEY_TRIGGER_REASON = "trigger_reason"
+
+class FolderSyncWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+ override suspend fun doWork(): Result = Result.success()
+
+ companion object {
+ const val WORK_NAME = "folder_sync_work"
+ const val WORK_NAME_ONETIME = "folder_sync_work_onetime"
+ const val KEY_METADATA_ONLY = "metadata_only"
+ const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
+ const val KEY_TRIGGER_REASON = "trigger_reason"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
index 97c8e4f..aec1d24 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/LibraryScreen.kt
@@ -144,14 +144,7 @@ import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity
-import org.dueattendant149.bookreader.opds.OpdsAcquisition
-import org.dueattendant149.bookreader.opds.OpdsCatalog
-import org.dueattendant149.bookreader.opds.OpdsDownloadState
-import org.dueattendant149.bookreader.opds.OpdsEntry
-import org.dueattendant149.bookreader.opds.OpdsRepository
-import org.dueattendant149.bookreader.opds.OpdsViewModel
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
-import org.dueattendant149.bookreader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -193,9 +186,6 @@ fun LibraryScreen(
add(context.getString(R.string.tab_all_books))
add(context.getString(R.string.tab_shelves))
add(context.getString(R.string.tab_folders))
- if (!BuildConfig.IS_OFFLINE) {
- add(context.getString(R.string.tab_catalogs))
- }
}
}
val pagerState = rememberPagerState(
@@ -379,16 +369,8 @@ fun LibraryScreen(
viewModel.showBanner(context.getString(R.string.banner_downloaded, title))
viewModel.onFileSelected(uri, isFromRecent = false)
},
- onStreamOpdsBook = { entry, catalog ->
- viewModel.streamOpdsBook(
- bookId = entry.id,
- title = entry.title,
- urlTemplate = entry.pseUrlTemplate!!,
- pageCount = entry.pseCount!!,
- catalogId = catalog?.id
- )
- },
- onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
+ onStreamOpdsBook = { _, _ -> },
+ onDeleteCatalogStreams = { },
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
onBookshelfClick = { navController.navigate(AppDestinations.BOOKSHELF_LIBRARY_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
@@ -662,7 +644,7 @@ fun LibraryScreenContent(
onRemoveFolderClick: (SyncedFolder) -> Unit,
onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
- onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
+ onStreamOpdsBook: (Any, Any?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit,
onBookshelfClick: () -> Unit,
@@ -965,30 +947,6 @@ fun LibraryScreenContent(
selectedShelves = selectedShelves
)
}
- 2 -> {
- FolderSyncScreen(
- syncedFolders = syncedFolders,
- allRecentFiles = rawLibraryFiles,
- onAddFolderClick = onSelectSyncFolderClick,
- onRemoveFolderClick = onRemoveFolderClick,
- onFolderLocalSyncChange = onFolderLocalSyncChange,
- onEditFolderFiltersClick = onEditFolderFiltersClick,
- onScanNowClick = onScanNowClick,
- onSyncMetadataClick = onSyncMetadataClick,
- isLoading = isLoading || isRefreshing
- )
- }
- 3 -> {
- if (!BuildConfig.IS_OFFLINE) {
- OpdsTab(
- localLibraryFiles = rawLibraryFiles,
- onBookDownloaded = onOpdsBookDownloaded,
- onReadBook = onItemClick,
- onStreamBook = onStreamOpdsBook,
- onDeleteCatalogStreams = onDeleteCatalogStreams
- )
- }
- }
}
}
}
@@ -2021,395 +1979,7 @@ private fun DeleteShelvesConfirmationDialog(
}
@Composable
-private fun FolderSyncScreen(
- syncedFolders: List,
- allRecentFiles: List,
- onAddFolderClick: () -> Unit,
- onRemoveFolderClick: (SyncedFolder) -> Unit,
- onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
- onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit,
- onScanNowClick: () -> Unit,
- onSyncMetadataClick: () -> Unit,
- isLoading: Boolean
-) {
- var editingFolder by remember { mutableStateOf(null) }
- var disablingFolder by remember { mutableStateOf(null) }
- val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
- val folderStatsByUri = remember(allRecentFiles) {
- allRecentFiles
- .asSequence()
- .filter { it.sourceFolderUri != null }
- .groupBy { it.sourceFolderUri!! }
- .mapValues { (_, files) ->
- FolderFileStats(
- totalBooks = files.size,
- countsByType = files.groupingBy { it.type }.eachCount()
- )
- }
- }
-
- Scaffold(
- floatingActionButton = {
- if (syncedFolders.size < 10) {
- ExtendedFloatingActionButton(
- text = { Text(stringResource(R.string.fab_add_folder)) },
- icon = { Icon(Icons.Default.Add, "Add") },
- onClick = onAddFolderClick
- )
- }
- }
- ) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- if (syncedFolders.isNotEmpty()) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- FilledTonalButton(
- onClick = onScanNowClick,
- enabled = !isLoading && hasEnabledSyncFolders,
- modifier = Modifier.weight(1f),
- shape = MaterialTheme.shapes.small
- ) {
- if (isLoading) {
- CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
- } else {
- Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
- }
- Spacer(modifier = Modifier.width(8.dp))
- Text(if (isLoading) stringResource(R.string.scanning) else stringResource(R.string.scan_all))
- }
-
- androidx.compose.material3.OutlinedButton(
- onClick = onSyncMetadataClick,
- enabled = !isLoading && hasEnabledSyncFolders,
- modifier = Modifier.weight(1f),
- shape = MaterialTheme.shapes.small
- ) {
- Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
- Spacer(modifier = Modifier.width(8.dp))
- Text(stringResource(R.string.sync_meta))
- }
- }
- } else {
- EmptyState(
- title = stringResource(R.string.sync_local_folders),
- message = stringResource(R.string.sync_folders_desc),
- onSelectFileClick = onAddFolderClick,
- primaryButtonText = stringResource(R.string.action_select_folder),
- modifier = Modifier.fillMaxSize()
- )
- }
-
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(bottom = 80.dp)
- ) {
- items(syncedFolders, key = { it.uriString }) { folder ->
- FolderCard(
- folder = folder,
- stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
- onRemoveClick = onRemoveFolderClick,
- onLocalSyncToggleClick = { selectedFolder ->
- if (selectedFolder.localSyncEnabled) {
- disablingFolder = selectedFolder
- } else {
- onFolderLocalSyncChange(selectedFolder, true, false)
- }
- },
- onEditFiltersClick = { editingFolder = folder }
- )
- }
- }
- }
- }
-
- editingFolder?.let { folder ->
- EditFolderFiltersDialog(
- folder = folder,
- onConfirm = { newFilters ->
- onEditFolderFiltersClick(folder, newFilters)
- editingFolder = null
- },
- onDismiss = { editingFolder = null }
- )
- }
-
- disablingFolder?.let { folder ->
- AlertDialog(
- onDismissRequest = { disablingFolder = null },
- title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
- text = {
- Text(
- stringResource(
- R.string.dialog_disable_folder_local_sync_desc,
- LOCAL_FOLDER_SYNC_DATA_DIR
- )
- )
- },
- confirmButton = {
- TextButton(
- onClick = {
- onFolderLocalSyncChange(folder, false, true)
- disablingFolder = null
- }
- ) {
- Text(stringResource(R.string.action_disable_remove_sync_data))
- }
- },
- dismissButton = {
- Row {
- TextButton(onClick = { disablingFolder = null }) {
- Text(stringResource(R.string.action_cancel))
- }
- TextButton(
- onClick = {
- onFolderLocalSyncChange(folder, false, false)
- disablingFolder = null
- }
- ) {
- Text(stringResource(R.string.action_disable_keep_sync_data))
- }
- }
- }
- )
- }
-}
-
-private data class FolderFileStats(
- val totalBooks: Int,
- val countsByType: Map
-) {
- companion object {
- val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
- }
-}
-
-@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
-@Composable
-private fun FolderCard(
- folder: SyncedFolder,
- stats: FolderFileStats,
- onRemoveClick: (SyncedFolder) -> Unit,
- onLocalSyncToggleClick: (SyncedFolder) -> Unit,
- onEditFiltersClick: (SyncedFolder) -> Unit
-) {
- var showMenu by remember { mutableStateOf(false) }
- val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
- val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
-
- androidx.compose.material3.ElevatedCard(
- modifier = Modifier.fillMaxWidth(),
- colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
- )
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
- Icon(
- imageVector = Icons.Default.FolderSpecial,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary
- )
- Spacer(modifier = Modifier.width(12.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = folder.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- if (!folder.localSyncEnabled) {
- Text(
- text = stringResource(R.string.folder_local_sync_disabled),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- }
-
- Box {
- IconButton(onClick = { showMenu = true }) {
- Icon(Icons.Default.MoreVert, "Options")
- }
- DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_edit_filters)) },
- onClick = {
- showMenu = false
- onEditFiltersClick(folder)
- }
- )
- DropdownMenuItem(
- text = {
- Text(
- if (folder.localSyncEnabled) {
- stringResource(R.string.menu_disable_folder_local_sync)
- } else {
- stringResource(R.string.menu_enable_folder_local_sync)
- }
- )
- },
- onClick = {
- showMenu = false
- onLocalSyncToggleClick(folder)
- }
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_remove_folder)) },
- onClick = {
- showMenu = false
- onRemoveClick(folder)
- },
- colors = androidx.compose.material3.MenuDefaults.itemColors(
- textColor = MaterialTheme.colorScheme.error
- )
- )
- }
- }
- }
-
- HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
-
- Row(modifier = Modifier.fillMaxWidth()) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = stringResource(R.string.last_sync),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- fontWeight = FontWeight.Bold
- )
- Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
- }
-
- Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
- Text(
- text = stringResource(R.string.books_count),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- fontWeight = FontWeight.Bold
- )
- Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
- }
- }
-
- if (stats.countsByType.isNotEmpty()) {
- Spacer(modifier = Modifier.height(12.dp))
- androidx.compose.foundation.layout.FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- stats.countsByType.forEach { (type, count) ->
- AssistChip(
- onClick = { },
- label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
- )
- }
- }
- }
- }
- }
-}
-
-@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
-@Composable
-private fun EditFolderFiltersDialog(
- folder: SyncedFolder,
- onConfirm: (Set) -> Unit,
- onDismiss: () -> Unit
-) {
- var selectedTypes by remember { mutableStateOf(folder.allowedFileTypes) }
-
- AlertDialog(
- onDismissRequest = onDismiss,
- title = {
- Column {
- Text(
- text = stringResource(R.string.filter_file_types),
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = stringResource(R.string.filter_file_types_desc),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- },
- text = {
- Column(modifier = Modifier.fillMaxWidth()) {
- HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
-
- androidx.compose.foundation.layout.FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- ANDROID_SYNCABLE_FILE_TYPES.forEach { type ->
- val isSelected = type in selectedTypes
- FilterChip(
- selected = isSelected,
- onClick = {
- selectedTypes = if (isSelected) {
- selectedTypes - type
- } else {
- selectedTypes + type
- }
- },
- label = {
- Text(
- text = type.name,
- style = MaterialTheme.typography.labelLarge
- )
- },
- leadingIcon = if (isSelected) {
- {
- Icon(
- imageVector = Icons.Default.Check,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- }
- } else null,
- shape = MaterialTheme.shapes.medium
- )
- }
- }
- }
- },
- confirmButton = {
- androidx.compose.material3.Button(
- onClick = { onConfirm(selectedTypes) },
- enabled = selectedTypes.isNotEmpty(),
- shape = MaterialTheme.shapes.medium
- ) {
- Text(stringResource(R.string.action_save))
- }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) {
- Text(stringResource(R.string.action_cancel))
- }
- }
- )
-}
-
@OptIn(ExperimentalMaterial3Api::class)
-@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
allTags: List,
@@ -2537,889 +2107,3 @@ fun LibraryFilterSheet(
}
}
-@Composable
-fun OpdsTab(
- localLibraryFiles: List,
- onBookDownloaded: (Uri, String) -> Unit,
- onReadBook: (RecentFileItem) -> Unit,
- onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
- onDeleteCatalogStreams: (String) -> Unit,
- opdsViewModel: OpdsViewModel = viewModel()
-) {
- val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
- val downloadingState = uiState.downloadingState
- val context = LocalContext.current
- val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
- var selectedEntry by remember { mutableStateOf(null) }
- var showCatalogDialog by remember { mutableStateOf(false) }
- var editingCatalog by remember { mutableStateOf(null) }
- var catalogToDelete by remember { mutableStateOf(null) }
-
- BackHandler(enabled = uiState.isViewingCatalog) {
- opdsViewModel.navigateBack()
- }
-
- Box(modifier = Modifier.fillMaxSize()) {
- if (!uiState.isViewingCatalog) {
- Box(modifier = Modifier.fillMaxSize()) {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(
- start = 16.dp,
- end = 16.dp,
- top = 16.dp,
- bottom = 88.dp
- ),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- items(uiState.catalogs, key = { it.id }) { catalog ->
- OpdsCatalogCard(
- catalog = catalog,
- onClick = { opdsViewModel.openCatalog(catalog) },
- onEdit = if (catalog.isDefault) null else {
- {
- editingCatalog = catalog
- showCatalogDialog = true
- }
- },
- onDelete = if (catalog.isDefault) null else {
- { catalogToDelete = catalog }
- })
- }
- }
-
- ExtendedFloatingActionButton(
- text = { Text(stringResource(R.string.fab_add_catalog)) },
- icon = { Icon(Icons.Default.Add, "Add") },
- onClick = {
- editingCatalog = null
- showCatalogDialog = true
- },
- modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
- )
- }
- } else {
- // Screen 2: Viewing a specific feed/catalog
- Box(modifier = Modifier.fillMaxSize()) {
- Column(modifier = Modifier.fillMaxSize()) {
- Surface(
- color = MaterialTheme.colorScheme.surface,
- tonalElevation = 2.dp,
- modifier = Modifier.fillMaxWidth()
- ) {
- var showSearch by remember { mutableStateOf(false) }
- var query by remember { mutableStateOf("") }
-
- val searchFocusRequester = remember { FocusRequester() }
-
- LaunchedEffect(showSearch) {
- if (showSearch) {
- delay(100)
- searchFocusRequester.requestFocus()
- }
- }
-
- Box(modifier = Modifier.fillMaxWidth()) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth().height(64.dp)
- .padding(horizontal = 4.dp)
- ) {
- IconButton(onClick = {
- if (showSearch) {
- showSearch = false
- query = ""
- } else {
- opdsViewModel.navigateBack()
- }
- }) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
- }
-
- if (showSearch) {
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- placeholder = { Text(stringResource(R.string.search_catalog_placeholder)) },
- modifier = Modifier.weight(1f).padding(vertical = 4.dp)
- .focusRequester(searchFocusRequester),
- singleLine = true,
- colors = TextFieldDefaults.colors(
- focusedContainerColor = Color.Transparent,
- unfocusedContainerColor = Color.Transparent,
- disabledContainerColor = Color.Transparent,
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- ),
- trailingIcon = {
- IconButton(onClick = {
- if (query.isNotBlank()) {
- opdsViewModel.search(query)
- showSearch = false
- query = ""
- }
- }) {
- Icon(Icons.Default.Search, "Search")
- }
- },
- keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
- imeAction = androidx.compose.ui.text.input.ImeAction.Search
- ),
- keyboardActions = androidx.compose.foundation.text.KeyboardActions(
- onSearch = {
- if (query.isNotBlank()) {
- opdsViewModel.search(query)
- showSearch = false
- query = ""
- }
- })
- )
- } else {
- Text(
- text = uiState.currentFeed?.title ?: stringResource(R.string.status_loading),
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
- )
- if (uiState.searchUrlTemplate != null) {
- IconButton(onClick = { showSearch = true }) {
- Icon(Icons.Default.Search, "Search")
- }
- }
- }
- }
-
- if (uiState.isLoading) {
- androidx.compose.material3.LinearProgressIndicator(
- modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
- )
- }
- }
- }
-
- if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Text(stringResource(R.string.feed_empty))
- }
- } else {
- val facets = uiState.currentFeed?.facets ?: emptyList()
- if (facets.isNotEmpty()) {
- val groups = facets.groupBy { it.group }
- LazyRow(
- modifier = Modifier.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- groups.forEach { (groupName, groupFacets) ->
- item(key = groupName) {
- var expanded by remember { mutableStateOf(false) }
- val activeFacet = groupFacets.find { it.isActive }
- ?: groupFacets.firstOrNull()
-
- Box {
- FilterChip(
- selected = activeFacet?.isActive == true,
- onClick = { expanded = true },
- label = { Text(stringResource(R.string.filter_facet, groupName, activeFacet?.title ?: stringResource(R.string.action_select))) },
- trailingIcon = {
- Icon(
- Icons.Default.ArrowDropDown,
- null
- )
- })
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false }) {
- groupFacets.forEach { facet ->
- DropdownMenuItem(
- text = { Text(facet.title) },
- onClick = {
- expanded = false
- opdsViewModel.openFeedUrl(facet.url)
- },
- trailingIcon = if (facet.isActive) {
- { Icon(Icons.Default.Check, null) }
- } else null)
- }
- }
- }
- }
- }
- }
- }
-
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- val entries = uiState.currentFeed?.entries ?: emptyList()
- itemsIndexed(
- entries,
- key = { index, item -> "${item.id}_$index" }) { index, entry ->
-
- if (index == entries.lastIndex) {
- LaunchedEffect(index) { opdsViewModel.loadNextPage() }
- }
-
- if (entry.isNavigation) {
- OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
- } else {
- OpdsBookCard(
- entry = entry,
- localLibraryFiles = localLibraryFiles,
- downloadState = downloadingState[entry.id],
- coverImageLoader = coverImageLoader,
- onDownloadClick = { acquisition ->
- opdsViewModel.downloadBook(
- entry, acquisition, context
- ) { downloadedUri ->
- onBookDownloaded(downloadedUri, entry.title)
- }
- },
- onReadClick = onReadBook,
- onStreamClick = {
- onStreamBook(
- entry,
- uiState.currentCatalog
- )
- },
- onClick = { selectedEntry = entry })
- }
- }
- }
- }
- }
- }
- }
-
- // Error Banner overlay
- uiState.errorMessage?.let { error ->
- LaunchedEffect(error) {
- delay(4000)
- opdsViewModel.clearError()
- }
- Surface(
- color = MaterialTheme.colorScheme.errorContainer,
- shape = MaterialTheme.shapes.medium,
- modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
- .padding(bottom = 70.dp)
- ) {
- Text(
- text = error,
- color = MaterialTheme.colorScheme.onErrorContainer,
- modifier = Modifier.padding(16.dp)
- )
- }
- }
-
- if (selectedEntry != null) {
- OpdsBookDetailsSheet(
- entry = selectedEntry!!,
- localLibraryFiles = localLibraryFiles,
- downloadState = downloadingState[selectedEntry!!.id],
- coverImageLoader = coverImageLoader,
- onDownloadFormat = { acquisition ->
- opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
- onBookDownloaded(downloadedUri, selectedEntry!!.title)
- }
- },
- onReadClick = onReadBook,
- onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } },
- onAuthorOrCategoryClick = { url, fallbackName ->
- if (url != null) opdsViewModel.openFeedUrl(url)
- else opdsViewModel.search(fallbackName)
- selectedEntry = null
- },
- onDismiss = { selectedEntry = null }
- )
- }
- }
-
- // Dynamic Add/Edit Dialog
- if (showCatalogDialog) {
- var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") }
- var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") }
- var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") }
- var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") }
-
- val isEditMode = editingCatalog != null
-
- AlertDialog(
- onDismissRequest = {
- showCatalogDialog = false
- editingCatalog = null
- },
- title = { Text(if (isEditMode) stringResource(R.string.edit_catalog) else stringResource(R.string.add_opds_catalog)) },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(
- value = newTitle,
- onValueChange = { newTitle = it },
- label = { Text(stringResource(R.string.catalog_name)) },
- singleLine = true
- )
- OutlinedTextField(
- value = newUrl,
- onValueChange = { newUrl = it },
- label = { Text(stringResource(R.string.url)) },
- placeholder = { Text(stringResource(R.string.url_placeholder)) },
- singleLine = true
- )
- Text(stringResource(R.string.auth_optional),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(top = 8.dp)
- )
- OutlinedTextField(
- value = newUsername,
- onValueChange = { newUsername = it },
- label = { Text(stringResource(R.string.username)) },
- singleLine = true
- )
- OutlinedTextField(
- value = newPassword,
- onValueChange = { newPassword = it },
- label = { Text(stringResource(R.string.password)) },
- singleLine = true,
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
- )
- }
- },
- confirmButton = {
- TextButton(
- onClick = {
- if (isEditMode) {
- opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword)
- } else {
- opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword)
- }
- showCatalogDialog = false
- editingCatalog = null
- },
- enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
- ) { Text(stringResource(R.string.action_save)) }
- },
- dismissButton = {
- TextButton(onClick = {
- showCatalogDialog = false
- editingCatalog = null
- }) { Text(stringResource(R.string.action_cancel)) }
- }
- )
- }
-
- if (catalogToDelete != null) {
- val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
- AlertDialog(
- onDismissRequest = { catalogToDelete = null },
- title = { Text(stringResource(R.string.delete_catalog)) },
- text = {
- Column {
- Text(stringResource(R.string.delete_catalog_desc, catalogToDelete!!.title))
- if (streamedBooksCount > 0) {
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- stringResource(R.string.delete_catalog_warning, streamedBooksCount),
- color = MaterialTheme.colorScheme.error
- )
- }
- }
- },
- confirmButton = {
- TextButton(
- onClick = {
- opdsViewModel.removeCatalog(catalogToDelete!!.id)
- if (streamedBooksCount > 0) {
- onDeleteCatalogStreams(catalogToDelete!!.id)
- }
- catalogToDelete = null
- },
- colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
- ) { Text(stringResource(R.string.action_delete)) }
- },
- dismissButton = {
- TextButton(onClick = { catalogToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
- }
- )
- }
-}
-
-@Composable
-private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
- val context = LocalContext.current.applicationContext
- val username = catalog?.username
- val password = catalog?.password
- val imageLoader = remember(context, username, password) {
- ImageLoader.Builder(context)
- .okHttpClient {
- OpdsRepository.sharedHttpClient.newBuilder()
- .authenticator(OpdsRepository.OpdsAuthenticator(username, password))
- .build()
- }
- .components {
- add(SvgDecoder.Factory())
- }
- .build()
- }
- DisposableEffect(imageLoader) {
- onDispose { imageLoader.shutdown() }
- }
- return imageLoader
-}
-
-@Composable
-fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
- Surface(
- onClick = onClick,
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.surfaceContainer,
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(16.dp)
- ) {
- Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- if (catalog.isDefault) {
- Spacer(modifier = Modifier.width(8.dp))
- Surface(
- color = MaterialTheme.colorScheme.secondaryContainer,
- shape = MaterialTheme.shapes.small
- ) {
- Text(stringResource(R.string.preset_label),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
- )
- }
- }
- }
- Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- if (onEdit != null) {
- IconButton(onClick = onEdit) {
- Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.label_edit))
- }
- }
- if (onDelete != null) {
- IconButton(onClick = onDelete) {
- Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_remove))
- }
- }
- }
- }
-}
-
-@Composable
-fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
- Surface(
- onClick = { entry.navigationUrl?.let { onClick(it) } },
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.surfaceContainerLow,
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(16.dp)
- ) {
- Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
- Spacer(modifier = Modifier.width(16.dp))
- Column {
- Text(entry.title, style = MaterialTheme.typography.titleMedium)
- entry.summary?.let {
- val cleanSummary = remember(it) { Jsoup.parse(it).text() }
- Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
- }
- }
- }
- }
-}
-
-@Composable
-fun OpdsBookCard(
- entry: OpdsEntry,
- localLibraryFiles: List,
- downloadState: OpdsDownloadState?,
- coverImageLoader: ImageLoader,
- onDownloadClick: (OpdsAcquisition) -> Unit,
- onReadClick: (RecentFileItem) -> Unit,
- onStreamClick: () -> Unit,
- onClick: () -> Unit
-) {
- val libraryItem = remember(entry, localLibraryFiles) {
- SharedOpdsLocalBookMatcher.find(
- entry = entry,
- books = localLibraryFiles,
- title = { it.title },
- displayName = { it.displayName },
- path = { it.uriString }
- )
- }
- val isDownloading = downloadState?.isDownloading == true
- val progress = downloadState?.progress
- val uniqueAcquisitions = remember(entry.acquisitions) {
- entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
- }
- var showFormatMenu by remember { mutableStateOf(false) }
-
- Surface(
- onClick = onClick,
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.surfaceContainerLow,
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(modifier = Modifier.padding(12.dp)) {
- AsyncImage(
- model = entry.coverUrl,
- contentDescription = null,
- imageLoader = coverImageLoader,
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(width = 70.dp, height = 100.dp)
- .clip(MaterialTheme.shapes.small)
- .background(MaterialTheme.colorScheme.surfaceVariant)
- )
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
- entry.author?.let {
- Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
- }
- entry.summary?.let {
- val cleanSummary = remember(it) { Jsoup.parse(it).text() }
- Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
- }
- Spacer(modifier = Modifier.height(8.dp))
-
- if (libraryItem != null) {
- androidx.compose.material3.OutlinedButton(
- onClick = { onReadClick(libraryItem) },
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
- ) {
- Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
- Spacer(modifier = Modifier.width(4.dp))
- Text(stringResource(R.string.action_read))
- }
- } else if (isDownloading) {
- Column(modifier = Modifier.fillMaxWidth()) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.labelMedium)
- Spacer(modifier = Modifier.weight(1f))
- if (progress != null) {
- Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
- }
- }
- Spacer(modifier = Modifier.height(4.dp))
- if (progress != null) {
- androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
- } else {
- androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
- }
- }
- } else {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- if (entry.isStreamable) {
- FilledTonalButton(
- onClick = onStreamClick,
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
- ) {
- Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
- Spacer(modifier = Modifier.width(4.dp))
- Text(stringResource(R.string.action_stream))
- }
- }
-
- Box {
- FilledTonalButton(
- onClick = {
- if (uniqueAcquisitions.size == 1) {
- onDownloadClick(uniqueAcquisitions.first())
- } else if (uniqueAcquisitions.size > 1) {
- showFormatMenu = true
- }
- },
- enabled = uniqueAcquisitions.isNotEmpty(),
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
- ) {
- if (uniqueAcquisitions.isEmpty()) {
- Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
- Spacer(modifier = Modifier.width(4.dp))
- Text(stringResource(R.string.action_unavailable))
- } else {
- Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
- Spacer(modifier = Modifier.width(4.dp))
- Text(stringResource(R.string.action_download))
- }
- }
- }
- DropdownMenu(
- expanded = showFormatMenu,
- onDismissRequest = { showFormatMenu = false }
- ) {
- uniqueAcquisitions.forEach { acq ->
- DropdownMenuItem(
- text = { Text(acq.formatName) },
- onClick = {
- showFormatMenu = false
- onDownloadClick(acq)
- }
- )
- }
- }
- }
- }
- }
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun OpdsBookDetailsSheet(
- entry: OpdsEntry,
- localLibraryFiles: List,
- downloadState: OpdsDownloadState?,
- coverImageLoader: ImageLoader,
- onDownloadFormat: (OpdsAcquisition) -> Unit,
- onReadClick: (RecentFileItem) -> Unit,
- onStreamClick: () -> Unit,
- onAuthorOrCategoryClick: (String?, String) -> Unit,
- onDismiss: () -> Unit
-) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
- val libraryItem = remember(entry, localLibraryFiles) {
- SharedOpdsLocalBookMatcher.find(
- entry = entry,
- books = localLibraryFiles,
- title = { it.title },
- displayName = { it.displayName },
- path = { it.uriString }
- )
- }
- val isDownloading = downloadState?.isDownloading == true
- val progress = downloadState?.progress
- val uniqueAcquisitions = remember(entry.acquisitions) {
- entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
- }
-
- ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 8.dp)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- AsyncImage(
- model = entry.coverUrl,
- contentDescription = null,
- imageLoader = coverImageLoader,
- contentScale = ContentScale.Crop,
- modifier = Modifier
- .size(width = 110.dp, height = 160.dp)
- .clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.surfaceVariant)
- )
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = entry.title,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- lineHeight = 28.sp
- )
-
- if (entry.authors.isNotEmpty()) {
- Spacer(modifier = Modifier.height(4.dp))
- FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- entry.authors.forEach { author ->
- Text(
- text = author.name,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.clickable {
- onAuthorOrCategoryClick(author.url, author.name)
- }
- )
- }
- }
- }
-
- entry.series?.takeIf { it.isNotBlank() }?.let { series ->
- Spacer(modifier = Modifier.height(8.dp))
- val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series
- Text(
- text = seriesText,
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.clickable {
- onAuthorOrCategoryClick(null, series)
- }
- )
- }
- }
- }
-
- if (libraryItem != null) {
- androidx.compose.material3.Button(
- onClick = {
- onDismiss()
- onReadClick(libraryItem)
- },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium
- ) {
- Icon(Icons.Default.Check, contentDescription = stringResource(R.string.action_read))
- Spacer(modifier = Modifier.width(8.dp))
- Text(stringResource(R.string.action_read), fontWeight = FontWeight.Bold)
- }
- }
-
- if (isDownloading) {
- Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.status_downloading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- Spacer(modifier = Modifier.weight(1f))
- if (progress != null) {
- Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
- }
- }
- Spacer(modifier = Modifier.height(8.dp))
- if (progress != null) {
- androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp))
- } else {
- androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp))
- }
- }
- } else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) {
- if (entry.isStreamable) {
- androidx.compose.material3.Button(
- onClick = {
- onStreamClick()
- onDismiss()
- },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium
- ) {
- Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
- Spacer(modifier = Modifier.width(8.dp))
- Text(stringResource(R.string.action_stream_now), fontWeight = FontWeight.Bold)
- }
- Spacer(modifier = Modifier.height(16.dp))
- }
-
- if (uniqueAcquisitions.isNotEmpty()) {
- Text(stringResource(R.string.download_format),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- uniqueAcquisitions.forEach { acq ->
- FilledTonalButton(onClick = { onDownloadFormat(acq) }) {
- Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp))
- Spacer(modifier = Modifier.width(8.dp))
- Text(acq.formatName, fontWeight = FontWeight.Bold)
- }
- }
- }
- }
- } else {
- Text(stringResource(R.string.no_supported_formats), color = MaterialTheme.colorScheme.error)
- }
-
- if (entry.categories.isNotEmpty()) {
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- entry.categories.distinct().forEach { category ->
- Surface(
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.surfaceVariant,
- onClick = { onAuthorOrCategoryClick(null, category) }
- ) {
- Text(
- text = category,
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
- )
- }
- }
- }
- }
-
- val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank()
- if (hasSecondaryMeta) {
- Surface(
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.surfaceContainer,
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- modifier = Modifier.padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- entry.publisher?.takeIf { it.isNotBlank() }?.let {
- Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(R.string.publisher), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
- }
- }
- entry.published?.takeIf { it.isNotBlank() }?.let {
- Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(R.string.published), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- val cleanDate = it.substringBefore("T")
- Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
- }
- }
- entry.language?.takeIf { it.isNotBlank() }?.let {
- Column(modifier = Modifier.weight(1f)) {
- Text(stringResource(R.string.language), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
- }
- }
- }
- }
- }
-
- val summary = entry.summary
- if (!summary.isNullOrBlank()) {
- Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
-
- val cleanSummary = remember(summary) {
- val preProcessed = summary
- .replace("
", "\n")
- .replace("
", "\n\n")
- Jsoup.parse(preProcessed).text().trim()
- }
-
- Text(
- text = cleanSummary,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
- lineHeight = 24.sp,
- modifier = Modifier.padding(bottom = 48.dp)
- )
- } else {
- Spacer(modifier = Modifier.height(48.dp))
- }
- }
- }
-}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
deleted file mode 100644
index 2f53581..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsModels.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.dueattendant149.bookreader.opds
-
-typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog
-typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet
-typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed
-typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor
-typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
-typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry
-typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
-typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
deleted file mode 100644
index d8af46f..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsParser.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.dueattendant149.bookreader.opds
-
-typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
deleted file mode 100644
index b19d280..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsRepository.kt
+++ /dev/null
@@ -1,214 +0,0 @@
-package org.dueattendant149.bookreader.opds
-
-import android.content.Context
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
-import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import timber.log.Timber
-import java.security.MessageDigest
-import java.util.UUID
-
-class OpdsRepository(context: Context) : SharedOpdsRepository {
- 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()
- .addInterceptor { chain ->
- val originalRequest = chain.request()
- val requestWithUserAgent = originalRequest.newBuilder()
- .header("User-Agent", "EpistemeReader/1.0 (Android)")
- .build()
- chain.proceed(requestWithUserAgent)
- }
- .build()
- }
- }
-
- private val httpClient = sharedHttpClient
-
- override fun loadCatalogs(): List {
- val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
- val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
- val catalogs = decodedCatalogs.ifEmpty {
- SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
- }
- if (decodedCatalogs.isEmpty()) {
- saveCatalogs(catalogs)
- }
- return catalogs
- }
-
- fun getCatalogs(): List = loadCatalogs()
-
- override suspend fun getSearchTemplate(
- openSearchUrl: String,
- username: String?,
- password: String?
- ): String? = withContext(Dispatchers.IO) {
- try {
- val request = Request.Builder().url(openSearchUrl).build()
- val response = getAuthenticatedClient(username, password).newCall(request).execute()
- val body = response.body?.string() ?: return@withContext null
- parser.extractOpenSearchTemplate(body, openSearchUrl)
- } catch (e: Exception) {
- Timber.e(e, "Failed to fetch OpenSearch template")
- null
- }
- }
-
- fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
- saveCatalogs(
- SharedOpdsCatalogs.addCatalog(
- catalogs = loadCatalogs(),
- title = title,
- url = url,
- username = username,
- password = password,
- idFactory = { UUID.randomUUID().toString() }
- )
- )
- }
-
- fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
- saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
- }
-
- fun removeCatalog(id: String) {
- saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
- }
-
- override fun saveCatalogs(catalogs: List) {
- prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
- }
-
- 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 = selectAuthQop(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 selectAuthQop(value: String?): String? {
- return value
- ?.split(',')
- ?.map { it.trim().trim('"') }
- ?.firstOrNull { it.equals("auth", ignoreCase = true) }
- }
-
- private fun md5(input: String): String {
- val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
- return bytes.joinToString("") { "%02x".format(it) }
- }
- }
-
-
- override suspend fun fetchFeed(url: String, username: String?, password: String?): Result = 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)
- }
- }
-}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
deleted file mode 100644
index 55388c2..0000000
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/opds/OpdsViewModel.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-package org.dueattendant149.bookreader.opds
-
-import android.app.Application
-import android.content.Context
-import android.net.Uri
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.viewModelScope
-import org.dueattendant149.bookreader.R
-import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
-import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okhttp3.Request
-import okhttp3.Response
-import timber.log.Timber
-import java.io.File
-import java.util.UUID
-
-class OpdsViewModel(application: Application) : AndroidViewModel(application) {
- private val repository = OpdsRepository(application)
- private val controller = SharedOpdsController(
- repository = repository,
- feedLoadErrorMessage = { error ->
- application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
- },
- idFactory = { UUID.randomUUID().toString() }
- )
-
- private val _uiState = MutableStateFlow(controller.state)
- val uiState: StateFlow = _uiState.asStateFlow()
-
- fun loadNextPage() {
- viewModelScope.launch {
- controller.loadNextPage(::emitState)
- }
- }
-
- fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
- val downloadUrl = acquisition.url
- val catalog = _uiState.value.currentCatalog
- viewModelScope.launch {
- updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
- try {
- val tempFile = withContext(Dispatchers.IO) {
- val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
- val request = Request.Builder().url(downloadUrl).build()
-
- client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) {
- throw OpdsDownloadFailedException(
- context.getString(R.string.opds_error_download_failed, response.message)
- )
- }
-
- val body = response.body
- ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
- val contentLength = body.contentLength()
- val ext = resolveOpdsDownloadExtension(acquisition, response)
- val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
- val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
-
- body.byteStream().use { input ->
- tempFile.outputStream().use { output ->
- val buffer = ByteArray(8 * 1024)
- var totalRead = 0L
- var lastProgressUpdate = System.currentTimeMillis()
-
- while (true) {
- val bytesRead = input.read(buffer)
- if (bytesRead == -1) break
- output.write(buffer, 0, bytesRead)
- totalRead += bytesRead
-
- if (contentLength > 0) {
- val now = System.currentTimeMillis()
- if (now - lastProgressUpdate > 200) {
- val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
- withContext(Dispatchers.Main) {
- updateDownloadState(
- entry.id,
- OpdsDownloadState(isDownloading = true, progress = progress)
- )
- }
- lastProgressUpdate = now
- }
- }
- }
- }
- }
- tempFile
- }
- }
-
- onDownloaded(Uri.fromFile(tempFile))
- } catch (e: Exception) {
- Timber.e(e, "Download error")
- val message = if (e is OpdsDownloadFailedException) {
- e.message.orEmpty()
- } else {
- context.getString(R.string.opds_error_download_error, e.message.orEmpty())
- }
- emitState(controller.setErrorMessage(message))
- } finally {
- updateDownloadState(entry.id, null)
- }
- }
- }
-
- private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
- return SharedOpdsDownloadNamer.resolveExtension(
- acquisition = acquisition,
- contentDisposition = response.header("Content-Disposition"),
- urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
- )
- }
-
- fun addCatalog(title: String, url: String, username: String?, password: String?) {
- emitState(controller.addCatalog(title, url, username, password))
- }
-
- fun removeCatalog(id: String) {
- emitState(controller.removeCatalog(id))
- }
-
- fun openCatalog(catalog: OpdsCatalog) {
- viewModelScope.launch {
- controller.openCatalog(catalog, ::emitState)
- }
- }
-
- fun openFeedUrl(url: String) {
- viewModelScope.launch {
- controller.openFeedUrl(url, ::emitState)
- }
- }
-
- fun navigateBack(): Boolean {
- val returnsToPreviousFeed = controller.hasFeedHistory()
- viewModelScope.launch {
- controller.navigateBack(::emitState)
- }
- return returnsToPreviousFeed
- }
-
- fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
- emitState(controller.updateCatalog(id, title, url, username, password))
- }
-
- fun search(query: String) {
- viewModelScope.launch {
- controller.search(query, ::emitState)
- }
- }
-
- fun clearError() {
- emitState(controller.clearError())
- }
-
- private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
- emitState(controller.updateDownloadState(entryId, downloadState))
- }
-
- private fun emitState(state: OpdsScreenState) {
- _uiState.value = state
- }
-
- private class OpdsDownloadFailedException(message: String) : Exception(message)
-}
diff --git a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
index 94c0e7d..912a2d4 100644
--- a/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
+++ b/app/src/main/java/com/dueattendant149/bookreader/reader/pdf/UniversalDocument.kt
@@ -783,20 +783,7 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
- private val catalog = catalogId?.let {
- org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
- }
-
- private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
- .apply {
- val streamCatalog = catalog
- val username = streamCatalog?.username
- val password = streamCatalog?.password
- if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
- authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
- }
- }
- .build()
+ private val client = okhttp3.OkHttpClient.Builder().build()
private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200)
@@ -827,18 +814,7 @@ class OpdsStreamDocumentWrapper(
}
}
- val streamCatalog = catalog
- val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
- try {
- val oldUrl = java.net.URL(urlTemplate)
- val newUrl = java.net.URL(streamCatalog.url)
- val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
- val newBase = "${newUrl.protocol}://${newUrl.authority}"
- urlTemplate.replace(oldBase, newBase)
- } catch (_: Exception) {
- urlTemplate
- }
- } else urlTemplate
+ val finalUrlTemplate = urlTemplate
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600")
diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
index d0b64d1..ae9aba7 100644
--- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
+++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfLibraryScreen.kt
@@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
+import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -47,6 +48,12 @@ fun BookshelfLibraryScreen(
}
}
+ LaunchedEffect(viewModel) {
+ viewModel.downloadedFile.collect { uri ->
+ Timber.d("Downloaded book to $uri, opening in reader")
+ }
+ }
+
Scaffold(
topBar = {
TopAppBar(
@@ -106,6 +113,9 @@ fun BookshelfLibraryScreen(
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
+ if (uiState.downloadingItem != null) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
@@ -113,7 +123,11 @@ fun BookshelfLibraryScreen(
items(uiState.items) { item ->
ItemCard(
item = item,
- onClick = { onItemClick(item) }
+ isDownloading = uiState.downloadingItem?.id == item.id,
+ onClick = {
+ viewModel.downloadBook(item)
+ onItemClick(item)
+ }
)
}
}
@@ -151,6 +165,7 @@ private fun LibraryCard(
@Composable
private fun ItemCard(
item: UnifiedItemResponse,
+ isDownloading: Boolean,
onClick: () -> Unit,
) {
Card(
@@ -176,6 +191,10 @@ private fun ItemCard(
text = item.type,
style = MaterialTheme.typography.bodySmall
)
+ if (isDownloading) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
+ Text("Downloading...", style = MaterialTheme.typography.bodySmall)
+ }
}
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
index 46c10f3..072d823 100644
--- a/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
+++ b/app/src/main/java/org/dueattendant149/bookreader/bookshelf/BookshelfViewModel.kt
@@ -1,27 +1,38 @@
package org.dueattendant149.bookreader.bookshelf
-import androidx.lifecycle.ViewModel
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
+import java.io.File
import javax.inject.Inject
@HiltViewModel
class BookshelfViewModel
@Inject
constructor(
+ application: Application,
private val repository: BookshelfApiRepository,
- ) : ViewModel() {
+ ) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(BookshelfUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ private val _downloadedFile = MutableSharedFlow(extraBufferCapacity = 1)
+ val downloadedFile: SharedFlow = _downloadedFile.asSharedFlow()
+
fun loadLibraries() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
@@ -101,6 +112,57 @@ class BookshelfViewModel
}
}
}
+
+ fun downloadBook(item: UnifiedItemResponse) {
+ _uiState.value = _uiState.value.copy(downloadingItem = item)
+ viewModelScope.launch {
+ repository.downloadEbook(item.id)
+ .onSuccess { body ->
+ val file = saveToCache(item, body)
+ if (file != null) {
+ _downloadedFile.tryEmit(Uri.fromFile(file))
+ }
+ _uiState.value = _uiState.value.copy(downloadingItem = null)
+ }
+ .onFailure { error ->
+ _uiState.value = _uiState.value.copy(
+ downloadingItem = null,
+ error = error.message ?: "Download failed"
+ )
+ }
+ }
+ }
+
+ private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? {
+ return runCatching {
+ val cacheDir = getApplication().cacheDir
+ val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() }
+ val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
+ val ext = guessExtension(item)
+ val file = File(dir, "${safeName}_${item.id}.$ext")
+ file.outputStream().use { out -> body.byteStream().copyTo(out) }
+ file
+ }.getOrElse {
+ _uiState.value = _uiState.value.copy(
+ downloadingItem = null,
+ error = "Failed to save file: ${it.message}"
+ )
+ null
+ }
+ }
+
+ private fun guessExtension(item: UnifiedItemResponse): String = when {
+ item.mediaType.contains("epub", ignoreCase = true) -> "epub"
+ item.mediaType.contains("pdf", ignoreCase = true) -> "pdf"
+ item.mediaType.contains("fb2", ignoreCase = true) -> "fb2"
+ item.mediaType.contains("mobi", ignoreCase = true) -> "mobi"
+ item.mediaType.contains("azw3", ignoreCase = true) -> "azw3"
+ item.mediaType.contains("docx", ignoreCase = true) -> "docx"
+ item.mediaType.contains("odt", ignoreCase = true) -> "odt"
+ item.mediaType.contains("txt", ignoreCase = true) -> "txt"
+ item.mediaType.contains("md", ignoreCase = true) -> "md"
+ else -> "epub"
+ }
}
@@ -110,4 +172,5 @@ data class BookshelfUiState(
val items: List = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
-)
+ val downloadingItem: UnifiedItemResponse? = null,
+)
\ No newline at end of file