/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ // FolderSyncWorker.kt package 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 com.aryan.reader.data.toSharedFolderBookMetadata import com.aryan.reader.shared.BookItem as SharedBookItem import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.EpubBookmark import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR import com.aryan.reader.shared.LocalFolderSyncEngine import com.aryan.reader.shared.ReaderLocator import com.aryan.reader.shared.SharedFolderScannedFile import com.aryan.reader.shared.SharedReaderScreenState import com.aryan.reader.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("synced_folders_list_json", null) val folders = mutableListOf>>() 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() 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, 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 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 ?: "Local Folder", lastScanTime = nowMillis, allowedFileTypes = allowedFileTypes ) val sharedState = SharedReaderScreenState( rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() }, syncedFolders = listOf(folder) ) val syncResult = LocalFolderSyncEngine.syncFolder( state = sharedState, folder = folder, files = scanResult.files, remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() }, nowMillis = nowMillis, metadataOnly = metadataOnly ) if (syncResult.idMigrations.isNotEmpty()) { val preloadedSidecars = ReaderPerfLog.measureSuspend( name = "FolderSync phase migration-sidecars", minLogMs = 25L ) { LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap() } syncResult.idMigrations.forEach { (oldId, newId) -> Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId") migrateFolderBookId( folderUriString = folderUriString, oldId = oldId, newId = newId, folderMetadataMap = folderMetadataMap, preloadedSidecars = preloadedSidecars, existingItemsMap = existingItemsMap ) } } if (!isFolderStillLinked(folderUriString)) { ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString") stoppedForUnlinkedFolder = true return true } val scannedFilesById = scanResult.files.associateBy { it.stableBookId } val syncedItems = syncResult.state.rawLibraryBooks.map { book -> val existing = existingItemsMap[book.id] val metadata = appliedMetadataFor( book = book, existing = existing, metadata = folderMetadataMap[book.id] ) book.toFolderSyncRecentFileItem( existing = existing, appliedMetadata = metadata, scannedFile = scannedFilesById[book.id], nowMillis = nowMillis ) } val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item } changedItems .filter { item -> val previous = existingItemsMap[item.bookId] previous != null && folderFileContentChanged(previous, item) } .forEach { item -> Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.") recentFilesRepository.clearLocalCachesForBook(item.bookId) } if (changedItems.isNotEmpty()) { recentFilesRepository.addRecentFiles(changedItems) dbFlushes++ } if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) { Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.") recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList()) } val booksForAnnotationSync = if (metadataOnly) { syncedItems } else { ReaderPerfLog.measureSuspend( name = "FolderSync phase load-post-scan-db", minLogMs = 25L ) { recentFilesRepository.getFilesBySourceFolder(folderUriString) } } sidecarsImported += importAnnotationSidecarsForBooks( folderUri = folderUri, folderUriString = folderUriString, books = booksForAnnotationSync, phase = if (metadataOnly) "metadata-only" else "post-scan" ) val elapsed = ReaderPerfLog.elapsedMs(folderStart) ReaderPerfLog.i( "FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " + "dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " + "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " + "remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " + "removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " + "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " + "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" ) if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) { if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) { ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString") val metaRequest = OneTimeWorkRequestBuilder() .setInputData( androidx.work.Data.Builder() .putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString) .build() ) .build() WorkManager.getInstance(appContext).enqueueUniqueWork( MetadataExtractionWorker.WORK_NAME, ExistingWorkPolicy.REPLACE, metaRequest ) } else { ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString") } } return true } catch (e: Exception) { Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") return false } } private suspend fun importAnnotationSidecarsForBooks( folderUri: android.net.Uri, folderUriString: String, books: List, phase: String ): Int { if (books.isEmpty()) { ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString") return 0 } val preloadedSidecars = ReaderPerfLog.measureSuspend( name = "FolderSync phase annotation-sidecars", minLogMs = 25L, details = { "phase=$phase" } ) { LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri) } ReaderPerfLog.d( "FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString" ) if (preloadedSidecars.isEmpty()) return 0 var imported = 0 Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...") for (book in books) { if (isStopped || !isFolderStillLinked(folderUriString)) break val sidecarData = preloadedSidecars[book.bookId] ?: continue val (remoteTs, jsonPayload) = sidecarData val safeSlashBookId = book.bookId.replace("/", "_") val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_") val localFiles = listOf( File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"), File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"), File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"), File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"), File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json") ) val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L if (remoteTs > (localTs + 1000)) { Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) imported++ } else { Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") } } ReaderPerfLog.i( "FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString" ) return imported } private data class AndroidFolderScanResult( val files: List = emptyList(), val dirsScanned: Int = 0, val filesSeen: Int = 0, val stoppedForUnlinkedFolder: Boolean = false ) private fun scanFolderFiles( folderUri: android.net.Uri, folderUriString: String, allowedFileTypes: Set ): AndroidFolderScanResult { Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") val contentResolver = appContext.contentResolver val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) val dirQueue = ArrayDeque() val scannedFiles = mutableListOf() var dirsScanned = 0 var filesSeen = 0 var stoppedForUnlinkedFolder = false dirQueue.add(rootDocId) val projection = arrayOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_LAST_MODIFIED ) while (dirQueue.isNotEmpty()) { if (isStopped) break if (!isFolderStillLinked(folderUriString)) { ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString") stoppedForUnlinkedFolder = true break } val currentDocId = dirQueue.removeFirst() dirsScanned++ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) try { contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) { val docId = cursor.getString(idCol) val name = cursor.getString(nameCol) ?: "" val mimeType = cursor.getString(mimeCol) filesSeen++ if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) { ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString") stoppedForUnlinkedFolder = true break } if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) { dirQueue.add(docId) } continue } val type = getFileType(name, mimeType) if ( type == null || type !in allowedFileTypes || !isLocalFolderSyncEligibleFile(name, mimeType) || name.endsWith(".json") || name.startsWith(".") ) { continue } val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) val relativePath = buildRelativePath(rootDocId, docId, name) scannedFiles += SharedFolderScannedFile( name = name, path = docUri.toString(), sourceFolder = folderUriString, relativePath = relativePath, type = type, size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L, lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L ) } } } catch (e: Exception) { Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId") } if (stoppedForUnlinkedFolder) break } return AndroidFolderScanResult( files = scannedFiles, dirsScanned = dirsScanned, filesSeen = filesSeen, stoppedForUnlinkedFolder = stoppedForUnlinkedFolder ) } private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem { return SharedBookItem( id = bookId, path = uriString, type = type, displayName = displayName, timestamp = lastModifiedTimestamp, coverImagePath = coverImagePath, title = title, author = author, description = description, originalTitle = originalTitle, originalAuthor = originalAuthor, originalSeriesName = originalSeriesName, originalSeriesIndex = originalSeriesIndex, originalDescription = originalDescription, progressPercentage = progressPercentage, isRecent = isRecent, fileSize = fileSize, fileContentModifiedTimestamp = fileContentModifiedTimestamp, sourceFolder = sourceFolderUri, folderTextMetadataParsed = folderTextMetadataParsed, seriesName = seriesName, seriesIndex = seriesIndex, lastPageIndex = lastPage, readerPosition = readerPositionOrNull(), readerBookmarks = parseReaderBookmarks(), readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson) ) } 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 = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex, locatorCharOffset = 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, tags = existing?.tags.orEmpty() ) } private fun appliedMetadataFor( book: SharedBookItem, existing: RecentFileItem?, metadata: FolderBookMetadata? ): FolderBookMetadata? { if (metadata == null) return null val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified } } private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? { if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null return ReaderLocator.fromLegacy( chapterIndex = lastChapterIndex, cfi = lastPositionCfi, pageIndex = lastPage ) } private fun RecentFileItem.parseReaderBookmarks(): List { return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson) .mapIndexed { index, bookmark -> val locator = bookmark.locator.withFallbacks( chapterIndex = bookmark.chapterIndex, cfi = bookmark.cfi, pageIndex = bookmark.pageInChapter?.minus(1), textQuote = bookmark.snippet ) val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0 ReaderBookmark( id = "bookmark_${bookId}_$index", pageIndex = pageIndex.coerceAtLeast(0), chapterTitle = bookmark.chapterTitle, preview = bookmark.snippet, locator = locator ) } } private fun List.toAndroidBookmarksJson(bookId: String): String? { val bookmarks = mapIndexed { index, bookmark -> val locator = bookmark.locator val chapterIndex = locator.chapterIndex ?: 0 val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}" EpubBookmark( cfi = cfi, chapterTitle = bookmark.chapterTitle, label = null, snippet = bookmark.preview, pageInChapter = bookmark.pageIndex + 1, totalPagesInChapter = null, chapterIndex = chapterIndex, locator = locator.withFallbacks( chapterIndex = chapterIndex, cfi = cfi, pageIndex = bookmark.pageIndex, textQuote = bookmark.preview ) ) } return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson) } private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean { val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize val modifiedChanged = next.fileContentModifiedTimestamp > 0L && previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp return sizeChanged || modifiedChanged } private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean { val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize val modifiedChanged = next.fileContentModifiedTimestamp > 0L && previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp return sizeChanged || modifiedChanged } private fun isFolderStillLinked(folderUriString: String): Boolean { val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) 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 buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { val rootPath = rootDocId.substringAfter(':', "") val docPath = docId.substringAfter(':', "") if (docPath.isBlank()) return fallbackName val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) { docPath.removePrefix(rootPath).trimStart('/') } else { docPath.substringAfterLast('/', fallbackName) } return relative.ifBlank { fallbackName } } private suspend fun migrateFolderBookId( folderUriString: String, oldId: String, newId: String, folderMetadataMap: MutableMap, preloadedSidecars: MutableMap>, existingItemsMap: MutableMap ) { if (oldId == newId) return recentFilesRepository.migrateBookIdLocally(oldId, newId) val oldMetadata = folderMetadataMap.remove(oldId) if (oldMetadata != null && newId !in folderMetadataMap) { val migratedMetadata = oldMetadata.copy(bookId = newId) LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata) folderMetadataMap[newId] = migratedMetadata } val oldSidecar = preloadedSidecars.remove(oldId) if (oldSidecar != null && newId !in preloadedSidecars) { LocalSyncUtils.saveAnnotationSidecar( context = appContext, sourceFolderUri = folderUriString.toUri(), bookId = newId, jsonPayload = oldSidecar.second, timestamp = oldSidecar.first ) preloadedSidecars[newId] = oldSidecar } LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId) existingItemsMap.remove(oldId) recentFilesRepository.getFileByBookId(newId)?.let { existingItemsMap[newId] = it } } }