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
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -38,6 +38,16 @@ 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
@ -53,7 +63,6 @@ class 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()
}
@ -151,11 +160,7 @@ class FolderSyncWorker(
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
@ -179,7 +184,7 @@ class FolderSyncWorker(
return false
}
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
val folderMetadataMap = ReaderPerfLog.measureSuspend(
name = "FolderSync phase metadata-sidecars",
@ -192,324 +197,148 @@ class FolderSyncWorker(
"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>()
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = existingFolderBooksById[bookId]
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 (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 (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 (remoteMetadataUpdates.isNotEmpty()) {
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
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++
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 && syncResult.removedBookIds.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
}
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(
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 = "post-scan"
)
}
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=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
"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"
)
@ -601,6 +430,294 @@ class FolderSyncWorker(
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)
@ -621,11 +738,6 @@ class FolderSyncWorker(
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(':', "")
@ -638,16 +750,6 @@ class FolderSyncWorker(
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,