feat(backend): Phase 2 complete — bookshelf-api + ABS, remove OPDS/Gutenberg/LocalFolder/CloudSync
Phase 2.4: download/open ebooks from server - BookshelfViewModel now extends AndroidViewModel, downloads books via BookshelfApiRepository.downloadEbook - Saves to cache dir, emits Uri via SharedFlow, opens in reader via MainViewModel.onFileSelected - Download progress indicator in BookshelfLibraryScreen item cards Phase 2.5: remove legacy backends - Delete reader/opds/ package (OpdsModels, OpdsParser, OpdsRepository, OpdsViewModel) - Delete CloudSyncTrace.kt and FolderSyncWorker.kt - Delete OpdsTab, rememberOpdsCoverImageLoader, OpdsCatalogCard, OpdsNavigationCard, OpdsBookCard, OpdsBookDetailsSheet from LibraryScreen - Delete FolderSyncScreen, FolderCard, EditFolderFiltersDialog from LibraryScreen - Remove 'Catalogs' tab from LibraryScreen tabTitles - Add CloudSyncTraceStub.kt with no-op stubs for logCloudSyncTrace/Error/Annotation variants - Add FolderSyncWorkerStub.kt as no-op CoroutineWorker with constants - Fix OpdsStreamDocumentWrapper to use plain OkHttpClient - ':app:assembleOssDebug' passes (APK ~80 MB).
This commit is contained in:
parent
1666ae0fe2
commit
fef33afe69
13 changed files with 163 additions and 2591 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: ""
|
||||
|
|
@ -1,773 +0,0 @@
|
|||
/*
|
||||
* Episteme Reader - A native Android document reader.
|
||||
* Copyright (C) 2026 Episteme
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* mail: epistemereader@gmail.com
|
||||
*/
|
||||
// FolderSyncWorker.kt
|
||||
package org.dueattendant149.bookreader
|
||||
|
||||
import android.content.Context
|
||||
import timber.log.Timber
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import org.dueattendant149.bookreader.data.RecentFileItem
|
||||
import org.dueattendant149.bookreader.data.RecentFilesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.content.edit
|
||||
import org.dueattendant149.bookreader.data.LocalSyncUtils
|
||||
import org.dueattendant149.bookreader.data.FolderBookMetadata
|
||||
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
|
||||
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
|
||||
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
|
||||
import org.dueattendant149.bookreader.shared.EpubBookmark
|
||||
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import java.io.File
|
||||
import android.provider.DocumentsContract
|
||||
|
||||
class FolderSyncWorker(
|
||||
private val appContext: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "FolderSyncWorker"
|
||||
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
|
||||
const val KEY_METADATA_ONLY = "key_metadata_only"
|
||||
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
|
||||
private val syncMutex = Mutex()
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val workerStart = ReaderPerfLog.nowNanos()
|
||||
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
|
||||
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||
jsonString = jsonString,
|
||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||
)
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val enabledFolders = folders.filter { it.localSyncEnabled }
|
||||
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||
enabledFolders
|
||||
} else {
|
||||
enabledFolders.filter { it.uriString == targetFolderUri }
|
||||
}
|
||||
|
||||
if (foldersToProcess.isEmpty()) {
|
||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
|
||||
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
|
||||
)
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
syncMutex.withLock {
|
||||
var allSuccess = true
|
||||
|
||||
for (folderConfig in foldersToProcess) {
|
||||
val success = performSyncForFolder(folderConfig, isMetadataOnly)
|
||||
if (!success) allSuccess = false
|
||||
}
|
||||
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
val array = org.json.JSONArray(jsonString)
|
||||
val now = System.currentTimeMillis()
|
||||
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
if (obj.optString("uri") in processedUris) {
|
||||
obj.put("lastScanTime", now)
|
||||
}
|
||||
}
|
||||
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
|
||||
ReaderPerfLog.i(
|
||||
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
|
||||
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
|
||||
)
|
||||
|
||||
if (allSuccess) Result.success() else Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
|
||||
val folderUriString = folderConfig.uriString
|
||||
val allowedFileTypes = folderConfig.allowedFileTypes
|
||||
if (folderUriString.isBlank()) return true
|
||||
val folderUri = folderUriString.toUri()
|
||||
val folderStart = ReaderPerfLog.nowNanos()
|
||||
var dirsScanned = 0
|
||||
var filesSeen = 0
|
||||
var supportedBooksSeen = 0
|
||||
var dbFlushes = 0
|
||||
var sidecarsImported = 0
|
||||
var stoppedForUnlinkedFolder = false
|
||||
|
||||
try {
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
appContext.contentResolver.takePersistableUriPermission(
|
||||
folderUri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
return false
|
||||
}
|
||||
|
||||
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
||||
if (documentTree == null || !documentTree.isDirectory) {
|
||||
return false
|
||||
}
|
||||
|
||||
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
|
||||
|
||||
val folderMetadataMap = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase metadata-sidecars",
|
||||
minLogMs = 25L,
|
||||
details = { "metadataOnly=$metadataOnly" }
|
||||
) {
|
||||
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||
}
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
|
||||
)
|
||||
|
||||
val existingFolderBooks = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-existing-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
||||
|
||||
val scanResult = if (metadataOnly) {
|
||||
AndroidFolderScanResult()
|
||||
} else {
|
||||
ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase scan-folder",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
scanFolderFiles(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
allowedFileTypes = allowedFileTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
dirsScanned = scanResult.dirsScanned
|
||||
filesSeen = scanResult.filesSeen
|
||||
supportedBooksSeen = scanResult.files.size
|
||||
stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
|
||||
|
||||
if (isStopped || stoppedForUnlinkedFolder) {
|
||||
ReaderPerfLog.w(
|
||||
"FolderSync folder aborted before shared engine stopped=$isStopped " +
|
||||
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
val nowMillis = System.currentTimeMillis()
|
||||
val folder = SyncedFolder(
|
||||
uriString = folderUriString,
|
||||
name = documentTree.name ?: folderConfig.name,
|
||||
lastScanTime = nowMillis,
|
||||
allowedFileTypes = allowedFileTypes,
|
||||
localSyncEnabled = true
|
||||
)
|
||||
val sharedState = SharedReaderScreenState(
|
||||
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
|
||||
syncedFolders = listOf(folder)
|
||||
)
|
||||
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||
state = sharedState,
|
||||
folder = folder,
|
||||
files = scanResult.files,
|
||||
remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
|
||||
nowMillis = nowMillis,
|
||||
metadataOnly = metadataOnly
|
||||
)
|
||||
|
||||
if (syncResult.idMigrations.isNotEmpty()) {
|
||||
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase migration-sidecars",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
|
||||
}
|
||||
syncResult.idMigrations.forEach { (oldId, newId) ->
|
||||
Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
|
||||
migrateFolderBookId(
|
||||
folderUriString = folderUriString,
|
||||
oldId = oldId,
|
||||
newId = newId,
|
||||
folderMetadataMap = folderMetadataMap,
|
||||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
return true
|
||||
}
|
||||
|
||||
val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
|
||||
val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
|
||||
val existing = existingItemsMap[book.id]
|
||||
val metadata = appliedMetadataFor(
|
||||
book = book,
|
||||
existing = existing,
|
||||
metadata = folderMetadataMap[book.id]
|
||||
)
|
||||
book.toFolderSyncRecentFileItem(
|
||||
existing = existing,
|
||||
appliedMetadata = metadata,
|
||||
scannedFile = scannedFilesById[book.id],
|
||||
nowMillis = nowMillis
|
||||
)
|
||||
}
|
||||
val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
|
||||
|
||||
changedItems
|
||||
.filter { item ->
|
||||
val previous = existingItemsMap[item.bookId]
|
||||
previous != null && folderFileContentChanged(previous, item)
|
||||
}
|
||||
.forEach { item ->
|
||||
Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
|
||||
recentFilesRepository.clearLocalCachesForBook(item.bookId)
|
||||
}
|
||||
|
||||
if (changedItems.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(changedItems)
|
||||
dbFlushes++
|
||||
}
|
||||
|
||||
if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
|
||||
Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
|
||||
recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
|
||||
}
|
||||
|
||||
val booksForAnnotationSync = if (metadataOnly) {
|
||||
syncedItems
|
||||
} else {
|
||||
ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-post-scan-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
}
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = booksForAnnotationSync,
|
||||
phase = if (metadataOnly) "metadata-only" else "post-scan"
|
||||
)
|
||||
|
||||
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
|
||||
ReaderPerfLog.i(
|
||||
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
|
||||
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
|
||||
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
||||
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
|
||||
"removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
|
||||
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
|
||||
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||
)
|
||||
|
||||
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||
ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
|
||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||
.setInputData(
|
||||
androidx.work.Data.Builder()
|
||||
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||
MetadataExtractionWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
metaRequest
|
||||
)
|
||||
} else {
|
||||
ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importAnnotationSidecarsForBooks(
|
||||
folderUri: android.net.Uri,
|
||||
folderUriString: String,
|
||||
books: List<RecentFileItem>,
|
||||
phase: String
|
||||
): Int {
|
||||
if (books.isEmpty()) {
|
||||
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
|
||||
return 0
|
||||
}
|
||||
|
||||
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase annotation-sidecars",
|
||||
minLogMs = 25L,
|
||||
details = { "phase=$phase" }
|
||||
) {
|
||||
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
|
||||
}
|
||||
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
|
||||
)
|
||||
|
||||
if (preloadedSidecars.isEmpty()) return 0
|
||||
|
||||
var imported = 0
|
||||
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
|
||||
for (book in books) {
|
||||
if (isStopped || !isFolderStillLinked(folderUriString)) break
|
||||
|
||||
val sidecarData = preloadedSidecars[book.bookId] ?: continue
|
||||
val (remoteTs, jsonPayload) = sidecarData
|
||||
|
||||
val safeSlashBookId = book.bookId.replace("/", "_")
|
||||
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
|
||||
val localFiles = listOf(
|
||||
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
|
||||
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
|
||||
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
|
||||
File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
|
||||
File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
|
||||
)
|
||||
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
||||
|
||||
if (remoteTs > (localTs + 1000)) {
|
||||
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
||||
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
||||
imported++
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
||||
}
|
||||
}
|
||||
|
||||
ReaderPerfLog.i(
|
||||
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
|
||||
)
|
||||
return imported
|
||||
}
|
||||
|
||||
private data class AndroidFolderScanResult(
|
||||
val files: List<SharedFolderScannedFile> = emptyList(),
|
||||
val dirsScanned: Int = 0,
|
||||
val filesSeen: Int = 0,
|
||||
val stoppedForUnlinkedFolder: Boolean = false
|
||||
)
|
||||
|
||||
private fun scanFolderFiles(
|
||||
folderUri: android.net.Uri,
|
||||
folderUriString: String,
|
||||
allowedFileTypes: Set<FileType>
|
||||
): AndroidFolderScanResult {
|
||||
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
||||
val contentResolver = appContext.contentResolver
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val dirQueue = ArrayDeque<String>()
|
||||
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
|
||||
var dirsScanned = 0
|
||||
var filesSeen = 0
|
||||
var stoppedForUnlinkedFolder = false
|
||||
dirQueue.add(rootDocId)
|
||||
|
||||
val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
while (dirQueue.isNotEmpty()) {
|
||||
if (isStopped) break
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
val currentDocId = dirQueue.removeFirst()
|
||||
dirsScanned++
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||
|
||||
try {
|
||||
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
|
||||
val docId = cursor.getString(idCol)
|
||||
val name = cursor.getString(nameCol) ?: ""
|
||||
val mimeType = cursor.getString(mimeCol)
|
||||
filesSeen++
|
||||
|
||||
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
|
||||
dirQueue.add(docId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (
|
||||
type == null ||
|
||||
type !in allowedFileTypes ||
|
||||
!isLocalFolderSyncEligibleFile(name, mimeType) ||
|
||||
name.endsWith(".json") ||
|
||||
name.startsWith(".")
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val relativePath = buildRelativePath(rootDocId, docId, name)
|
||||
scannedFiles += SharedFolderScannedFile(
|
||||
name = name,
|
||||
path = docUri.toString(),
|
||||
sourceFolder = folderUriString,
|
||||
relativePath = relativePath,
|
||||
type = type,
|
||||
size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
|
||||
lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||
}
|
||||
|
||||
if (stoppedForUnlinkedFolder) break
|
||||
}
|
||||
|
||||
return AndroidFolderScanResult(
|
||||
files = scannedFiles,
|
||||
dirsScanned = dirsScanned,
|
||||
filesSeen = filesSeen,
|
||||
stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
|
||||
)
|
||||
}
|
||||
|
||||
private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
|
||||
return SharedBookItem(
|
||||
id = bookId,
|
||||
path = uriString,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = lastModifiedTimestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
sourceFolder = sourceFolderUri,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = readerPositionOrNull(),
|
||||
readerBookmarks = parseReaderBookmarks(),
|
||||
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
|
||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedBookItem.toFolderSyncRecentFileItem(
|
||||
existing: RecentFileItem?,
|
||||
appliedMetadata: FolderBookMetadata?,
|
||||
scannedFile: SharedFolderScannedFile?,
|
||||
nowMillis: Long
|
||||
): RecentFileItem {
|
||||
val contentChanged = existing != null && folderFileContentChanged(existing, this)
|
||||
val localModifiedTimestamp = when {
|
||||
appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
|
||||
contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
|
||||
timestamp > 0L -> timestamp
|
||||
else -> nowMillis
|
||||
}
|
||||
val legacyPosition = readerPosition
|
||||
val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
|
||||
val mappedHighlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val bookmarksJson = if (appliedMetadata != null || existing == null) {
|
||||
mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
|
||||
} else {
|
||||
existing.bookmarksJson
|
||||
}
|
||||
val highlightsJson = if (appliedMetadata != null || existing == null) {
|
||||
mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
|
||||
} else {
|
||||
existing.highlightsJson
|
||||
}
|
||||
|
||||
return RecentFileItem(
|
||||
bookId = id,
|
||||
uriString = path,
|
||||
type = type,
|
||||
displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
|
||||
timestamp = when {
|
||||
existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
|
||||
appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
|
||||
else -> existing.timestamp
|
||||
},
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
author = author,
|
||||
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
|
||||
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
|
||||
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
|
||||
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
|
||||
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
isAvailable = true,
|
||||
lastModifiedTimestamp = localModifiedTimestamp,
|
||||
isDeleted = false,
|
||||
bookmarksJson = bookmarksJson,
|
||||
sourceFolderUri = sourceFolder,
|
||||
isReflowPreferred = existing?.isReflowPreferred ?: false,
|
||||
customName = appliedMetadata?.customName ?: existing?.customName,
|
||||
highlightsJson = highlightsJson,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
description = description,
|
||||
originalTitle = originalTitle,
|
||||
originalAuthor = originalAuthor,
|
||||
originalSeriesName = originalSeriesName,
|
||||
originalSeriesIndex = originalSeriesIndex,
|
||||
originalDescription = originalDescription,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
|
||||
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
|
||||
tags = existing?.tags.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun appliedMetadataFor(
|
||||
book: SharedBookItem,
|
||||
existing: RecentFileItem?,
|
||||
metadata: FolderBookMetadata?
|
||||
): FolderBookMetadata? {
|
||||
if (metadata == null) return null
|
||||
val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
|
||||
return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
|
||||
}
|
||||
|
||||
private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
|
||||
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||
return ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
)
|
||||
}
|
||||
|
||||
private fun RecentFileItem.parseReaderBookmarks(): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator.withFallbacks(
|
||||
chapterIndex = bookmark.chapterIndex,
|
||||
cfi = bookmark.cfi,
|
||||
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||
textQuote = bookmark.snippet
|
||||
)
|
||||
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||
ReaderBookmark(
|
||||
id = "bookmark_${bookId}_$index",
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
preview = bookmark.snippet,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderBookmark>.toAndroidBookmarksJson(bookId: String): String? {
|
||||
val bookmarks = mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator
|
||||
val chapterIndex = locator.chapterIndex ?: 0
|
||||
val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
|
||||
EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
label = null,
|
||||
snippet = bookmark.preview,
|
||||
pageInChapter = bookmark.pageIndex + 1,
|
||||
totalPagesInChapter = null,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = bookmark.pageIndex,
|
||||
textQuote = bookmark.preview
|
||||
)
|
||||
)
|
||||
}
|
||||
return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
}
|
||||
|
||||
private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
|
||||
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||
return sizeChanged || modifiedChanged
|
||||
}
|
||||
|
||||
private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
|
||||
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
|
||||
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
|
||||
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
|
||||
return sizeChanged || modifiedChanged
|
||||
}
|
||||
|
||||
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
return SyncedFolderPrefs.isLocalSyncEnabled(
|
||||
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
|
||||
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
|
||||
folderUriString = folderUriString,
|
||||
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
|
||||
)
|
||||
}
|
||||
|
||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||
return resolveFileTypeFromMetadata(name, mimeType)
|
||||
}
|
||||
|
||||
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
|
||||
val rootPath = rootDocId.substringAfter(':', "")
|
||||
val docPath = docId.substringAfter(':', "")
|
||||
if (docPath.isBlank()) return fallbackName
|
||||
val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
|
||||
docPath.removePrefix(rootPath).trimStart('/')
|
||||
} else {
|
||||
docPath.substringAfterLast('/', fallbackName)
|
||||
}
|
||||
return relative.ifBlank { fallbackName }
|
||||
}
|
||||
|
||||
private suspend fun migrateFolderBookId(
|
||||
folderUriString: String,
|
||||
oldId: String,
|
||||
newId: String,
|
||||
folderMetadataMap: MutableMap<String, FolderBookMetadata>,
|
||||
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
|
||||
existingItemsMap: MutableMap<String, RecentFileItem>
|
||||
) {
|
||||
if (oldId == newId) return
|
||||
|
||||
recentFilesRepository.migrateBookIdLocally(oldId, newId)
|
||||
|
||||
val oldMetadata = folderMetadataMap.remove(oldId)
|
||||
if (oldMetadata != null && newId !in folderMetadataMap) {
|
||||
val migratedMetadata = oldMetadata.copy(bookId = newId)
|
||||
LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
|
||||
folderMetadataMap[newId] = migratedMetadata
|
||||
}
|
||||
|
||||
val oldSidecar = preloadedSidecars.remove(oldId)
|
||||
if (oldSidecar != null && newId !in preloadedSidecars) {
|
||||
LocalSyncUtils.saveAnnotationSidecar(
|
||||
context = appContext,
|
||||
sourceFolderUri = folderUriString.toUri(),
|
||||
bookId = newId,
|
||||
jsonPayload = oldSidecar.second,
|
||||
timestamp = oldSidecar.first
|
||||
)
|
||||
preloadedSidecars[newId] = oldSidecar
|
||||
}
|
||||
|
||||
LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
|
||||
|
||||
existingItemsMap.remove(oldId)
|
||||
recentFilesRepository.getFileByBookId(newId)?.let {
|
||||
existingItemsMap[newId] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.dueattendant149.bookreader.opds
|
||||
|
||||
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
package org.dueattendant149.bookreader.opds
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
class OpdsRepository(context: Context) : SharedOpdsRepository {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
|
||||
private val parser = OpdsParser()
|
||||
|
||||
companion object {
|
||||
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
|
||||
|
||||
val sharedHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
val requestWithUserAgent = originalRequest.newBuilder()
|
||||
.header("User-Agent", "EpistemeReader/1.0 (Android)")
|
||||
.build()
|
||||
chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private val httpClient = sharedHttpClient
|
||||
|
||||
override fun loadCatalogs(): List<OpdsCatalog> {
|
||||
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
|
||||
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
|
||||
val catalogs = decodedCatalogs.ifEmpty {
|
||||
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
|
||||
}
|
||||
if (decodedCatalogs.isEmpty()) {
|
||||
saveCatalogs(catalogs)
|
||||
}
|
||||
return catalogs
|
||||
}
|
||||
|
||||
fun getCatalogs(): List<OpdsCatalog> = loadCatalogs()
|
||||
|
||||
override suspend fun getSearchTemplate(
|
||||
openSearchUrl: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(openSearchUrl).build()
|
||||
val response = getAuthenticatedClient(username, password).newCall(request).execute()
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
parser.extractOpenSearchTemplate(body, openSearchUrl)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch OpenSearch template")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
|
||||
saveCatalogs(
|
||||
SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password,
|
||||
idFactory = { UUID.randomUUID().toString() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||
saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String) {
|
||||
saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
|
||||
}
|
||||
|
||||
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
|
||||
}
|
||||
|
||||
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
|
||||
return httpClient.newBuilder()
|
||||
.authenticator(OpdsAuthenticator(username, password))
|
||||
.build()
|
||||
}
|
||||
|
||||
class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
|
||||
private var cnonceCount = 0
|
||||
|
||||
override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
|
||||
if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
|
||||
|
||||
if (response.request.header("Authorization") != null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val wwwAuth = response.header("WWW-Authenticate") ?: return null
|
||||
|
||||
if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
|
||||
val credential = okhttp3.Credentials.basic(user, pass)
|
||||
return response.request.newBuilder().header("Authorization", credential).build()
|
||||
}
|
||||
|
||||
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
|
||||
val realm = extractParam(wwwAuth, "realm") ?: ""
|
||||
val nonce = extractParam(wwwAuth, "nonce") ?: ""
|
||||
val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
|
||||
val opaque = extractParam(wwwAuth, "opaque")
|
||||
|
||||
cnonceCount++
|
||||
val nc = String.format("%08x", cnonceCount)
|
||||
val cnonce = UUID.randomUUID().toString().replace("-", "")
|
||||
|
||||
val url = response.request.url
|
||||
val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
|
||||
|
||||
val ha1 = md5("$user:$realm:$pass")
|
||||
val ha2 = md5("${response.request.method}:$uri")
|
||||
|
||||
val responseHash = if (qop != null) {
|
||||
md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
|
||||
} else {
|
||||
md5("$ha1:$nonce:$ha2")
|
||||
}
|
||||
|
||||
val digestHeader = buildString {
|
||||
append("Digest username=\"$user\", ")
|
||||
append("realm=\"$realm\", ")
|
||||
append("nonce=\"$nonce\", ")
|
||||
append("uri=\"$uri\", ")
|
||||
append("response=\"$responseHash\"")
|
||||
if (qop != null) {
|
||||
append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
|
||||
}
|
||||
if (opaque != null) {
|
||||
append(", opaque=\"$opaque\"")
|
||||
}
|
||||
}
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", digestHeader)
|
||||
.build()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractParam(header: String, param: String): String? {
|
||||
val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
|
||||
return match?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
private fun selectAuthQop(value: String?): String? {
|
||||
return value
|
||||
?.split(',')
|
||||
?.map { it.trim().trim('"') }
|
||||
?.firstOrNull { it.equals("auth", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun md5(input: String): String {
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
|
||||
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
|
||||
try {
|
||||
val client = getAuthenticatedClient(username, password)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url.trim())
|
||||
.header("User-Agent", "EpistemeReader/1.0 (Android)")
|
||||
.build()
|
||||
|
||||
Timber.tag("OpdsDebug").d("Executing network call...")
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val errorMsg = "HTTP ${response.code}: ${response.message}"
|
||||
Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
|
||||
return@withContext Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val bodyString = response.body?.string()
|
||||
if (bodyString.isNullOrBlank()) {
|
||||
return@withContext Result.failure(Exception("Empty response body"))
|
||||
}
|
||||
|
||||
val feed = parser.parse(bodyString, url)
|
||||
|
||||
Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
|
||||
Result.success(feed)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
package org.dueattendant149.bookreader.opds
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import org.dueattendant149.bookreader.R
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
|
||||
import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = OpdsRepository(application)
|
||||
private val controller = SharedOpdsController(
|
||||
repository = repository,
|
||||
feedLoadErrorMessage = { error ->
|
||||
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
|
||||
},
|
||||
idFactory = { UUID.randomUUID().toString() }
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(controller.state)
|
||||
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadNextPage() {
|
||||
viewModelScope.launch {
|
||||
controller.loadNextPage(::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
val downloadUrl = acquisition.url
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
viewModelScope.launch {
|
||||
updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
|
||||
try {
|
||||
val tempFile = withContext(Dispatchers.IO) {
|
||||
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||
val request = Request.Builder().url(downloadUrl).build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw OpdsDownloadFailedException(
|
||||
context.getString(R.string.opds_error_download_failed, response.message)
|
||||
)
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
|
||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||
|
||||
body.byteStream().use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var totalRead = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
while (true) {
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressUpdate > 200) {
|
||||
val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateDownloadState(
|
||||
entry.id,
|
||||
OpdsDownloadState(isDownloading = true, progress = progress)
|
||||
)
|
||||
}
|
||||
lastProgressUpdate = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
}
|
||||
|
||||
onDownloaded(Uri.fromFile(tempFile))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Download error")
|
||||
val message = if (e is OpdsDownloadFailedException) {
|
||||
e.message.orEmpty()
|
||||
} else {
|
||||
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
|
||||
}
|
||||
emitState(controller.setErrorMessage(message))
|
||||
} finally {
|
||||
updateDownloadState(entry.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
|
||||
return SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = acquisition,
|
||||
contentDisposition = response.header("Content-Disposition"),
|
||||
urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
|
||||
)
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?) {
|
||||
emitState(controller.addCatalog(title, url, username, password))
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String) {
|
||||
emitState(controller.removeCatalog(id))
|
||||
}
|
||||
|
||||
fun openCatalog(catalog: OpdsCatalog) {
|
||||
viewModelScope.launch {
|
||||
controller.openCatalog(catalog, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun openFeedUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
controller.openFeedUrl(url, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
val returnsToPreviousFeed = controller.hasFeedHistory()
|
||||
viewModelScope.launch {
|
||||
controller.navigateBack(::emitState)
|
||||
}
|
||||
return returnsToPreviousFeed
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||
emitState(controller.updateCatalog(id, title, url, username, password))
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
viewModelScope.launch {
|
||||
controller.search(query, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
emitState(controller.clearError())
|
||||
}
|
||||
|
||||
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
|
||||
emitState(controller.updateDownloadState(entryId, downloadState))
|
||||
}
|
||||
|
||||
private fun emitState(state: OpdsScreenState) {
|
||||
_uiState.value = state
|
||||
}
|
||||
|
||||
private class OpdsDownloadFailedException(message: String) : Exception(message)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BookshelfUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
|
||||
val downloadedFile: SharedFlow<Uri> = _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<Application>().cacheDir
|
||||
val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() }
|
||||
val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
|
||||
val ext = guessExtension(item)
|
||||
val file = File(dir, "${safeName}_${item.id}.$ext")
|
||||
file.outputStream().use { out -> body.byteStream().copyTo(out) }
|
||||
file
|
||||
}.getOrElse {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
downloadingItem = null,
|
||||
error = "Failed to save file: ${it.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun guessExtension(item: UnifiedItemResponse): String = when {
|
||||
item.mediaType.contains("epub", ignoreCase = true) -> "epub"
|
||||
item.mediaType.contains("pdf", ignoreCase = true) -> "pdf"
|
||||
item.mediaType.contains("fb2", ignoreCase = true) -> "fb2"
|
||||
item.mediaType.contains("mobi", ignoreCase = true) -> "mobi"
|
||||
item.mediaType.contains("azw3", ignoreCase = true) -> "azw3"
|
||||
item.mediaType.contains("docx", ignoreCase = true) -> "docx"
|
||||
item.mediaType.contains("odt", ignoreCase = true) -> "odt"
|
||||
item.mediaType.contains("txt", ignoreCase = true) -> "txt"
|
||||
item.mediaType.contains("md", ignoreCase = true) -> "md"
|
||||
else -> "epub"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -110,4 +172,5 @@ data class BookshelfUiState(
|
|||
val items: List<UnifiedItemResponse> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
val downloadingItem: UnifiedItemResponse? = null,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue