book-reader/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
Aryan 9510293ac3
Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
2026-05-20 22:14:01 +05:30

791 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 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<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 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<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)
)
}
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<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)
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<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
}
}
}