v1.0.47 (#279)
* Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51)
This commit is contained in:
parent
f42de6b462
commit
d7a9cae9e1
126 changed files with 15287 additions and 3154 deletions
|
|
@ -24,11 +24,11 @@ import android.content.Context
|
|||
import timber.log.Timber
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.RecentFilesRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -53,11 +53,15 @@ class FolderSyncWorker(
|
|||
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 const val SCAN_DB_BATCH_SIZE = 600
|
||||
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("synced_folders_list_json", null)
|
||||
|
|
@ -87,17 +91,31 @@ class FolderSyncWorker(
|
|||
}
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
|
||||
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.")
|
||||
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||
folders
|
||||
} else {
|
||||
folders.filter { it.first == targetFolderUri }
|
||||
}
|
||||
|
||||
if (foldersToProcess.isEmpty()) {
|
||||
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked 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 ((uriString, allowedTypes) in folders) {
|
||||
for ((uriString, allowedTypes) in foldersToProcess) {
|
||||
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||
if (!success) allSuccess = false
|
||||
}
|
||||
|
|
@ -107,12 +125,21 @@ class FolderSyncWorker(
|
|||
val array = org.json.JSONArray(jsonString)
|
||||
val now = System.currentTimeMillis()
|
||||
for (i in 0 until array.length()) {
|
||||
array.getJSONObject(i).put("lastScanTime", now)
|
||||
val obj = array.getJSONObject(i)
|
||||
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
|
||||
obj.put("lastScanTime", now)
|
||||
}
|
||||
}
|
||||
prefs.edit { putString("synced_folders_list_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()
|
||||
}
|
||||
}
|
||||
|
|
@ -121,8 +148,24 @@ class FolderSyncWorker(
|
|||
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||
if (folderUriString.isBlank()) return true
|
||||
val folderUri = folderUriString.toUri()
|
||||
val folderStart = ReaderPerfLog.nowNanos()
|
||||
var dirsScanned = 0
|
||||
var filesSeen = 0
|
||||
var supportedBooksSeen = 0
|
||||
var newBooks = 0
|
||||
var updatedBooks = 0
|
||||
var unchangedBooks = 0
|
||||
var dbFlushes = 0
|
||||
var scanDbFlushes = 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,
|
||||
|
|
@ -137,17 +180,31 @@ class FolderSyncWorker(
|
|||
return false
|
||||
}
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...")
|
||||
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
|
||||
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||
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"
|
||||
)
|
||||
|
||||
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
|
||||
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
|
||||
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
|
||||
val existingFolderBooks = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-existing-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId }
|
||||
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
|
||||
|
||||
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
val existingItem = existingFolderBooksById[bookId]
|
||||
|
||||
if (existingItem != null) {
|
||||
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||
|
|
@ -166,40 +223,28 @@ class FolderSyncWorker(
|
|||
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
||||
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||
)
|
||||
recentFilesRepository.addRecentFile(itemToUpdate)
|
||||
remoteMetadataUpdates.add(itemToUpdate)
|
||||
} else {
|
||||
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
|
||||
val processedBookIds = mutableSetOf<String>()
|
||||
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
if (remoteMetadataUpdates.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
|
||||
dbFlushes++
|
||||
ReaderPerfLog.d(
|
||||
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
|
||||
)
|
||||
}
|
||||
|
||||
for (book in existingFolderBooks) {
|
||||
processedBookIds.add(book.bookId)
|
||||
|
||||
val sidecarData = preloadedSidecars[book.bookId]
|
||||
|
||||
if (sidecarData != null) {
|
||||
val (remoteTs, jsonPayload) = sidecarData
|
||||
|
||||
val localFiles = listOf(
|
||||
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.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)
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
||||
}
|
||||
}
|
||||
if (metadataOnly) {
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = existingFolderBooks,
|
||||
phase = "metadata-only"
|
||||
)
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
|
|
@ -207,7 +252,17 @@ class FolderSyncWorker(
|
|||
val contentResolver = appContext.contentResolver
|
||||
val foundBookIds = mutableSetOf<String>()
|
||||
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
||||
val existingItemsMap = existingFolderBooksById.toMutableMap()
|
||||
val existingItemsByUri = existingFolderBooks
|
||||
.mapNotNull { item -> item.uriString?.let { uri -> uri to item } }
|
||||
.toMap()
|
||||
val legacyItemsByName = existingFolderBooks
|
||||
.asSequence()
|
||||
.filter { it.bookId.startsWith("local_${it.displayName}_") }
|
||||
.groupBy { it.displayName }
|
||||
.mapValues { entry ->
|
||||
ArrayDeque<RecentFileItem>().apply { addAll(entry.value) }
|
||||
}
|
||||
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||
val dirQueue = ArrayDeque<String>()
|
||||
|
|
@ -223,7 +278,13 @@ class FolderSyncWorker(
|
|||
|
||||
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 {
|
||||
|
|
@ -234,10 +295,17 @@ class FolderSyncWorker(
|
|||
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
|
||||
while (cursor.moveToNext() && !isStopped) {
|
||||
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 != "EpistemeSyncData") {
|
||||
|
|
@ -249,13 +317,15 @@ class FolderSyncWorker(
|
|||
|
||||
val type = getFileType(name, mimeType)
|
||||
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||
supportedBooksSeen++
|
||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||
foundBookIds.add(stableId)
|
||||
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||
val docUriString = docUri.toString()
|
||||
var existingItem = existingItemsMap[stableId]
|
||||
|
||||
if (existingItem != null && existingItem.uriString != docUri.toString()) {
|
||||
if (existingItem != null && existingItem.uriString != docUriString) {
|
||||
val collidedItem = existingItem
|
||||
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
||||
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
||||
|
|
@ -273,12 +343,10 @@ class FolderSyncWorker(
|
|||
}
|
||||
|
||||
if (existingItem == null) {
|
||||
val oldItem = existingItemsMap.values.find {
|
||||
it.bookId != stableId && (
|
||||
it.uriString == docUri.toString() ||
|
||||
it.bookId.startsWith("local_${name}_")
|
||||
)
|
||||
}
|
||||
val oldItem = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
|
||||
?: legacyItemsByName[name]?.firstOrNull {
|
||||
it.bookId != stableId
|
||||
}
|
||||
if (oldItem != null) {
|
||||
val oldId = oldItem.bookId
|
||||
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
|
||||
|
|
@ -291,6 +359,7 @@ class FolderSyncWorker(
|
|||
preloadedSidecars = preloadedSidecars,
|
||||
existingItemsMap = existingItemsMap
|
||||
)
|
||||
legacyItemsByName[name]?.remove(oldItem)
|
||||
existingItem = existingItemsMap[stableId]
|
||||
}
|
||||
}
|
||||
|
|
@ -324,6 +393,7 @@ class FolderSyncWorker(
|
|||
fileSize = size
|
||||
)
|
||||
newOrUpdatedItems.add(newItem)
|
||||
newBooks++
|
||||
} else {
|
||||
var needsUpdate = false
|
||||
var updatedItem = existingItem
|
||||
|
|
@ -331,7 +401,11 @@ class FolderSyncWorker(
|
|||
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
||||
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||
updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified)
|
||||
updatedItem = updatedItem.copy(
|
||||
fileSize = size,
|
||||
lastModifiedTimestamp = lastModified,
|
||||
folderTextMetadataParsed = false
|
||||
)
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
|
|
@ -347,32 +421,26 @@ class FolderSyncWorker(
|
|||
|
||||
if (needsUpdate) {
|
||||
newOrUpdatedItems.add(updatedItem)
|
||||
updatedBooks++
|
||||
} else {
|
||||
unchangedBooks++
|
||||
}
|
||||
}
|
||||
|
||||
if (newOrUpdatedItems.size >= 50) {
|
||||
val batchLimit = if (scanDbFlushes == 0) 40 else SCAN_DB_BATCH_SIZE
|
||||
if (newOrUpdatedItems.size >= batchLimit) {
|
||||
if (!isFolderStillLinked(folderUriString)) {
|
||||
ReaderPerfLog.w("FolderSync batch dropped: folder unlinked pending=${newOrUpdatedItems.size} folder=$folderUriString")
|
||||
newOrUpdatedItems.clear()
|
||||
stoppedForUnlinkedFolder = true
|
||||
break
|
||||
}
|
||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
newOrUpdatedItems.clear()
|
||||
}
|
||||
|
||||
if (!processedBookIds.contains(stableId)) {
|
||||
val sidecarData = preloadedSidecars[stableId]
|
||||
if (sidecarData != null) {
|
||||
val (remoteTs, jsonPayload) = sidecarData
|
||||
val localFiles = listOf(
|
||||
File(appContext.filesDir, "annotations/annotation_$stableId.json"),
|
||||
File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"),
|
||||
File(appContext.filesDir, "page_layouts/layout_$stableId.json"),
|
||||
File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.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 new book $stableId. Importing.")
|
||||
recentFilesRepository.importAnnotationBundle(stableId, jsonPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -380,16 +448,19 @@ class FolderSyncWorker(
|
|||
} catch (e: Exception) {
|
||||
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||
}
|
||||
|
||||
if (stoppedForUnlinkedFolder) break
|
||||
}
|
||||
|
||||
if (newOrUpdatedItems.isNotEmpty()) {
|
||||
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
|
||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||
dbFlushes++
|
||||
scanDbFlushes++
|
||||
newOrUpdatedItems.clear()
|
||||
}
|
||||
|
||||
if (!isStopped) {
|
||||
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
||||
if (!isStopped && !stoppedForUnlinkedFolder) {
|
||||
val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
||||
|
|
@ -398,16 +469,50 @@ class FolderSyncWorker(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isStopped) {
|
||||
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||
MetadataExtractionWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
metaRequest
|
||||
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
|
||||
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
|
||||
name = "FolderSync phase load-post-scan-db",
|
||||
minLogMs = 25L
|
||||
) {
|
||||
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||
}
|
||||
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||
folderUri = folderUri,
|
||||
folderUriString = folderUriString,
|
||||
books = booksForAnnotationSync,
|
||||
phase = "post-scan"
|
||||
)
|
||||
}
|
||||
|
||||
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
|
||||
ReaderPerfLog.i(
|
||||
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
|
||||
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
|
||||
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
|
||||
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
|
||||
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||
)
|
||||
|
||||
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||
ReaderPerfLog.i("FolderSync enqueue text 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 text metadata extraction skipped: no pending books folder=$folderUriString")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -416,23 +521,87 @@ class FolderSyncWorker(
|
|||
}
|
||||
}
|
||||
|
||||
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 localFiles = listOf(
|
||||
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
|
||||
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.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 fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||
if (jsonString != null) {
|
||||
return try {
|
||||
val array = org.json.JSONArray(jsonString)
|
||||
(0 until array.length()).any { index ->
|
||||
array.getJSONObject(index).optString("uri") == folderUriString
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return prefs.getString("synced_folder_uri", null) == folderUriString
|
||||
}
|
||||
|
||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||
val lowerName = name.lowercase()
|
||||
return when {
|
||||
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
||||
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
|
||||
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
|
||||
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
|
||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
|
||||
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
|
||||
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
|
||||
lowerName.endsWith(".cbz") -> FileType.CBZ
|
||||
lowerName.endsWith(".cbr") -> FileType.CBR
|
||||
lowerName.endsWith(".cb7") -> FileType.CB7
|
||||
lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD
|
||||
lowerName.endsWith(".txt") -> FileType.TXT
|
||||
mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML
|
||||
else -> null
|
||||
return when (mimeType) {
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
else -> resolveFileTypeFromName(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue