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