feat(backend): Phase 2 complete — bookshelf-api + ABS, remove OPDS/Gutenberg/LocalFolder/CloudSync

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).
This commit is contained in:
Atte149 2026-06-24 16:41:38 +03:00
parent 1666ae0fe2
commit fef33afe69
13 changed files with 163 additions and 2591 deletions

View file

@ -435,10 +435,17 @@ fun AppNavigation(
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) { composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel() 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( BookshelfLibraryScreen(
viewModel = bookshelfViewModel, viewModel = bookshelfViewModel,
onItemClick = { item -> onItemClick = { item ->
Timber.d("Bookshelf item selected: ${item.id}") Timber.d("Bookshelf item selected: ${item.id}")
bookshelfViewModel.downloadBook(item)
}, },
onOpenSettings = { onOpenSettings = {
navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE) navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)

View file

@ -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})"
}
}

View file

@ -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 ?: ""

View file

@ -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 <https://www.gnu.org/licenses/>.
*
* 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<MetadataExtractionWorker>()
.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<RecentFileItem>,
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<SharedFolderScannedFile> = emptyList(),
val dirsScanned: Int = 0,
val filesSeen: Int = 0,
val stoppedForUnlinkedFolder: Boolean = false
)
private fun scanFolderFiles(
folderUri: android.net.Uri,
folderUriString: String,
allowedFileTypes: Set<FileType>
): 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<String>()
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
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<ReaderBookmark> {
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<ReaderBookmark>.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<String, FolderBookMetadata>,
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
existingItemsMap: MutableMap<String, RecentFileItem>
) {
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
}
}
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -1,3 +0,0 @@
package org.dueattendant149.bookreader.opds
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser

View file

@ -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<OpdsCatalog> {
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<OpdsCatalog> = 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<OpdsCatalog>) {
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<OpdsFeed> = withContext(Dispatchers.IO) {
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
try {
val client = getAuthenticatedClient(username, password)
val request = Request.Builder()
.url(url.trim())
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
Timber.tag("OpdsDebug").d("Executing network call...")
val response = client.newCall(request).execute()
Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
if (!response.isSuccessful) {
val errorMsg = "HTTP ${response.code}: ${response.message}"
Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
return@withContext Result.failure(Exception(errorMsg))
}
val bodyString = response.body?.string()
if (bodyString.isNullOrBlank()) {
return@withContext Result.failure(Exception("Empty response body"))
}
val feed = parser.parse(bodyString, url)
Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
Result.success(feed)
} catch (e: Exception) {
Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
Result.failure(e)
}
}
}

View file

@ -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<OpdsScreenState> = _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)
}

View file

@ -783,20 +783,7 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument { ) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() } private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
private val catalog = catalogId?.let { private val client = okhttp3.OkHttpClient.Builder().build()
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 fun createErrorPageBytes(): ByteArray { private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200) val bitmap = createBitmap(800, 1200)
@ -827,18 +814,7 @@ class OpdsStreamDocumentWrapper(
} }
} }
val streamCatalog = catalog val finalUrlTemplate = urlTemplate
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 url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString()) val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600") .replace("{maxWidth}", "1600")

View file

@ -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.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -47,6 +48,12 @@ fun BookshelfLibraryScreen(
} }
} }
LaunchedEffect(viewModel) {
viewModel.downloadedFile.collect { uri ->
Timber.d("Downloaded book to $uri, opening in reader")
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -106,6 +113,9 @@ fun BookshelfLibraryScreen(
if (uiState.isLoading) { if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }
if (uiState.downloadingItem != null) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn( LazyColumn(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -113,7 +123,11 @@ fun BookshelfLibraryScreen(
items(uiState.items) { item -> items(uiState.items) { item ->
ItemCard( ItemCard(
item = item, 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 @Composable
private fun ItemCard( private fun ItemCard(
item: UnifiedItemResponse, item: UnifiedItemResponse,
isDownloading: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Card( Card(
@ -176,6 +191,10 @@ private fun ItemCard(
text = item.type, text = item.type,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
if (isDownloading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Text("Downloading...", style = MaterialTheme.typography.bodySmall)
}
} }
} }
} }

View file

@ -1,27 +1,38 @@
package org.dueattendant149.bookreader.bookshelf 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository 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.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import java.io.File
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookshelfViewModel class BookshelfViewModel
@Inject @Inject
constructor( constructor(
application: Application,
private val repository: BookshelfApiRepository, private val repository: BookshelfApiRepository,
) : ViewModel() { ) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(BookshelfUiState()) private val _uiState = MutableStateFlow(BookshelfUiState())
val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow() val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow()
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
val downloadedFile: SharedFlow<Uri> = _downloadedFile.asSharedFlow()
fun loadLibraries() { fun loadLibraries() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null) _uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch { 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<Application>().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<UnifiedItemResponse> = emptyList(), val items: List<UnifiedItemResponse> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val downloadingItem: UnifiedItemResponse? = null,
) )