* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
689 lines
35 KiB
Kotlin
689 lines
35 KiB
Kotlin
/*
|
|
* 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 com.aryan.reader
|
|
|
|
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 com.aryan.reader.data.RecentFileItem
|
|
import com.aryan.reader.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 com.aryan.reader.data.LocalSyncUtils
|
|
import com.aryan.reader.data.FolderBookMetadata
|
|
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 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)
|
|
val folders = mutableListOf<Pair<String, Set<FileType>>>()
|
|
|
|
if (jsonString != null) {
|
|
try {
|
|
val array = org.json.JSONArray(jsonString)
|
|
for (i in 0 until array.length()) {
|
|
val obj = array.getJSONObject(i)
|
|
val uri = obj.getString("uri")
|
|
val allowedFileTypes = mutableSetOf<FileType>()
|
|
if (obj.has("allowedFileTypes")) {
|
|
val typesArray = obj.getJSONArray("allowedFileTypes")
|
|
for (j in 0 until typesArray.length()) {
|
|
try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
|
|
}
|
|
} else {
|
|
allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
|
|
}
|
|
folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
|
|
}
|
|
} catch (e: Exception) { Timber.e(e) }
|
|
} else {
|
|
val single = prefs.getString("synced_folder_uri", null)
|
|
if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
|
|
}
|
|
|
|
if (folders.isEmpty()) {
|
|
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
|
return Result.success()
|
|
}
|
|
|
|
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 foldersToProcess) {
|
|
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
|
if (!success) allSuccess = false
|
|
}
|
|
|
|
if (jsonString != null) {
|
|
try {
|
|
val array = org.json.JSONArray(jsonString)
|
|
val now = System.currentTimeMillis()
|
|
for (i in 0 until array.length()) {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
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 skipped")
|
|
|
|
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 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 = existingFolderBooksById[bookId]
|
|
|
|
if (existingItem != null) {
|
|
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
|
Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}")
|
|
val itemToUpdate = existingItem.copy(
|
|
lastChapterIndex = remoteMeta.lastChapterIndex,
|
|
lastPage = remoteMeta.lastPage,
|
|
lastPositionCfi = remoteMeta.lastPositionCfi,
|
|
progressPercentage = remoteMeta.progressPercentage,
|
|
bookmarksJson = remoteMeta.bookmarksJson,
|
|
highlightsJson = remoteMeta.highlightsJson,
|
|
customName = remoteMeta.customName,
|
|
locatorBlockIndex = remoteMeta.locatorBlockIndex,
|
|
locatorCharOffset = remoteMeta.locatorCharOffset,
|
|
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
|
|
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
|
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
|
)
|
|
remoteMetadataUpdates.add(itemToUpdate)
|
|
} else {
|
|
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
|
}
|
|
}
|
|
}
|
|
|
|
if (remoteMetadataUpdates.isNotEmpty()) {
|
|
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
|
|
dbFlushes++
|
|
ReaderPerfLog.d(
|
|
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
|
|
)
|
|
}
|
|
|
|
if (metadataOnly) {
|
|
sidecarsImported += importAnnotationSidecarsForBooks(
|
|
folderUri = folderUri,
|
|
folderUriString = folderUriString,
|
|
books = existingFolderBooks,
|
|
phase = "metadata-only"
|
|
)
|
|
}
|
|
|
|
if (!metadataOnly) {
|
|
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
|
|
val contentResolver = appContext.contentResolver
|
|
val foundBookIds = mutableSetOf<String>()
|
|
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
|
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>()
|
|
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 != "EpistemeSyncData") {
|
|
dirQueue.add(docId)
|
|
}
|
|
} else {
|
|
val size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L
|
|
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
|
|
|
|
val type = getFileType(name, mimeType)
|
|
if (
|
|
type != null &&
|
|
type in allowedFileTypes &&
|
|
isLocalFolderSyncEligibleFile(name, mimeType) &&
|
|
!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 != docUriString) {
|
|
val collidedItem = existingItem
|
|
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
|
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
|
Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId")
|
|
migrateFolderBookId(
|
|
folderUriString = folderUriString,
|
|
oldId = collidedItem.bookId,
|
|
newId = collidedStableId,
|
|
folderMetadataMap = folderMetadataMap,
|
|
preloadedSidecars = preloadedSidecars,
|
|
existingItemsMap = existingItemsMap
|
|
)
|
|
existingItem = existingItemsMap[stableId]
|
|
}
|
|
}
|
|
|
|
if (existingItem == null) {
|
|
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")
|
|
|
|
migrateFolderBookId(
|
|
folderUriString = folderUriString,
|
|
oldId = oldId,
|
|
newId = stableId,
|
|
folderMetadataMap = folderMetadataMap,
|
|
preloadedSidecars = preloadedSidecars,
|
|
existingItemsMap = existingItemsMap
|
|
)
|
|
legacyItemsByName[name]?.remove(oldItem)
|
|
existingItem = existingItemsMap[stableId]
|
|
}
|
|
}
|
|
|
|
if (existingItem == null) {
|
|
val remoteMeta = folderMetadataMap[stableId]
|
|
|
|
val newItem = RecentFileItem(
|
|
bookId = stableId,
|
|
uriString = docUri.toString(),
|
|
type = type,
|
|
displayName = name,
|
|
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
|
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
|
coverImagePath = null,
|
|
title = name.substringBeforeLast('.', name),
|
|
author = remoteMeta?.author,
|
|
isAvailable = true,
|
|
isDeleted = false,
|
|
isRecent = remoteMeta?.isRecent ?: false,
|
|
sourceFolderUri = folderUriString,
|
|
lastChapterIndex = remoteMeta?.lastChapterIndex,
|
|
lastPage = remoteMeta?.lastPage,
|
|
lastPositionCfi = remoteMeta?.lastPositionCfi,
|
|
progressPercentage = remoteMeta?.progressPercentage,
|
|
bookmarksJson = remoteMeta?.bookmarksJson,
|
|
highlightsJson = remoteMeta?.highlightsJson,
|
|
customName = remoteMeta?.customName,
|
|
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
|
locatorCharOffset = remoteMeta?.locatorCharOffset,
|
|
fileSize = size,
|
|
fileContentModifiedTimestamp = lastModified
|
|
)
|
|
newOrUpdatedItems.add(newItem)
|
|
newBooks++
|
|
} else {
|
|
var needsUpdate = false
|
|
var updatedItem = existingItem
|
|
|
|
val modifiedChanged = lastModified > 0L &&
|
|
existingItem.fileContentModifiedTimestamp != lastModified
|
|
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
|
|
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
|
|
recentFilesRepository.clearLocalCachesForBook(stableId)
|
|
updatedItem = updatedItem.copy(
|
|
fileSize = size,
|
|
fileContentModifiedTimestamp = lastModified,
|
|
lastModifiedTimestamp = lastModified,
|
|
coverImagePath = null,
|
|
title = name.substringBeforeLast('.', name),
|
|
author = null,
|
|
seriesName = null,
|
|
seriesIndex = null,
|
|
description = null,
|
|
originalTitle = null,
|
|
originalAuthor = null,
|
|
originalSeriesName = null,
|
|
originalSeriesIndex = null,
|
|
originalDescription = null,
|
|
folderTextMetadataParsed = false,
|
|
folderCoverMetadataParsed = false
|
|
)
|
|
needsUpdate = true
|
|
}
|
|
|
|
if (updatedItem.isDeleted || !updatedItem.isAvailable) {
|
|
updatedItem = updatedItem.copy(isDeleted = false, isAvailable = true)
|
|
needsUpdate = true
|
|
}
|
|
|
|
if (updatedItem.uriString != docUri.toString()) {
|
|
updatedItem = updatedItem.copy(uriString = docUri.toString())
|
|
needsUpdate = true
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
newOrUpdatedItems.add(updatedItem)
|
|
updatedBooks++
|
|
} else {
|
|
unchangedBooks++
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
|
}
|
|
|
|
if (stoppedForUnlinkedFolder) break
|
|
}
|
|
|
|
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
|
|
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
|
dbFlushes++
|
|
scanDbFlushes++
|
|
newOrUpdatedItems.clear()
|
|
}
|
|
|
|
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.")
|
|
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 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? {
|
|
return resolveFileTypeFromMetadata(name, mimeType)
|
|
}
|
|
|
|
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
|
|
val relativePath = buildRelativePath(rootDocId, docId, name)
|
|
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
|
}
|
|
|
|
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 fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
|
|
val uriString = item.uriString ?: return null
|
|
return try {
|
|
val docId = DocumentsContract.getDocumentId(uriString.toUri())
|
|
buildStableBookId(item.displayName, rootDocId, docId)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|