Refactored folder synchronization logic and improved migration handling (#21)

- Updated `MainViewModel` to skip synchronization until folder migration is completed and added metadata syncing when opening books.
- Modified folder migration to detach existing folder books (converting them to standard local files) before starting a fresh scan.
- Added `detachAllFolderBooks` to `RecentFileDao` and `RecentFilesRepository` to facilitate the migration process.
- Updated `MetadataExtractionWorker` to sync local metadata to the folder immediately after extraction.
- Simplified `FolderSyncWorker` by removing legacy migration lookups and streamlining the reconciliation of local and remote metadata.
- Updated `FolderMigrationDialog` UI text to reflect that books are now read directly from folders without duplication.
- Fixed various indentation and formatting issues across several files.
This commit is contained in:
Aryan 2026-03-02 03:03:36 +05:30 committed by GitHub
parent 34c6ec3fef
commit 0885b6949e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 105 additions and 196 deletions

View file

@ -114,116 +114,52 @@ class FolderSyncWorker(
} }
} }
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val legacyLookup = activeDbBooks.associateBy { it.displayName }
val foundBookIds = mutableSetOf<String>() val foundBookIds = mutableSetOf<String>()
for (file in currentDiskFiles) { for (file in currentDiskFiles) {
val stableId = "local_${file.name}_${file.length()}" val stableId = "local_${file.name}_${file.length()}"
foundBookIds.add(stableId)
var existingItem = recentFilesRepository.getFileByBookId(stableId) val existingItem = recentFilesRepository.getFileByBookId(stableId)
var bookIdToUse = stableId
var isMigration = false
if (existingItem == null) {
val legacyMatch = legacyLookup[file.name]
if (legacyMatch != null) {
Timber.tag("FolderSync").i("Migration: Found legacy match for ${file.name}. ID: ${legacyMatch.bookId}")
existingItem = legacyMatch
bookIdToUse = legacyMatch.bookId
isMigration = true
}
}
foundBookIds.add(bookIdToUse)
if (existingItem == null) { if (existingItem == null) {
val remoteMeta = folderMetadataMap[stableId] val remoteMeta = folderMetadataMap[stableId]
val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
// --- CHANGED: Removed extractFileInfo() call ---
// We use placeholders. The MetadataExtractionWorker will fix this later.
val placeholderTitle = file.name ?: "Unknown" val placeholderTitle = file.name ?: "Unknown"
val placeholderAuthor = null
val placeholderCover = null
if (remoteMeta != null) {
Timber.tag("FolderSync").d("Worker: Importing existing book from Metadata + File: ${file.name}")
// We prefer remoteMeta if available because it might have the correct title/author from a previous sync
val tempItem = RecentFileItem(
bookId = stableId,
uriString = file.uri.toString(),
type = type,
displayName = file.name ?: "Unknown",
timestamp = remoteMeta.lastModifiedTimestamp,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
coverImagePath = null, // Will be fetched by MetadataWorker if needed
title = remoteMeta.title ?: placeholderTitle,
author = remoteMeta.author,
isAvailable = true,
isDeleted = false,
isRecent = false,
sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson
)
recentFilesRepository.addRecentFile(tempItem)
} else {
// FAST PATH: Insert barebones item
val newItem = RecentFileItem( val newItem = RecentFileItem(
bookId = stableId, bookId = stableId,
uriString = file.uri.toString(), uriString = file.uri.toString(),
type = type, type = type,
displayName = file.name ?: "Unknown", displayName = file.name ?: "Unknown",
timestamp = System.currentTimeMillis(), timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
coverImagePath = null, // Background worker will fill this lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
title = placeholderTitle, coverImagePath = null,
author = null, title = remoteMeta?.title ?: placeholderTitle,
author = remoteMeta?.author,
isAvailable = true, isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false, isDeleted = false,
isRecent = false, isRecent = false,
sourceFolderUri = folderUriString sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta?.lastChapterIndex,
lastPage = remoteMeta?.lastPage,
lastPositionCfi = remoteMeta?.lastPositionCfi,
progressPercentage = remoteMeta?.progressPercentage,
bookmarksJson = remoteMeta?.bookmarksJson,
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
locatorCharOffset = remoteMeta?.locatorCharOffset
) )
recentFilesRepository.addRecentFile(newItem) recentFilesRepository.addRecentFile(newItem)
}
} else { } else {
if (isMigration) { var itemToUpdate = existingItem
val oldUriString = existingItem.uriString
val newUriString = file.uri.toString()
if (oldUriString != newUriString) { if (existingItem.isDeleted) {
Timber.tag("FolderSync").i("Migration: Updating URI and cleaning up internal storage for $bookIdToUse") itemToUpdate = itemToUpdate.copy(isDeleted = false, isAvailable = true)
if (oldUriString != null) {
bookImporter.deleteBookByUriString(oldUriString)
} }
existingItem = existingItem.copy( val remoteMeta = folderMetadataMap[stableId]
uriString = newUriString, if (remoteMeta != null && remoteMeta.lastModifiedTimestamp > itemToUpdate.lastModifiedTimestamp) {
isAvailable = true itemToUpdate = itemToUpdate.copy(
)
recentFilesRepository.addRecentFile(existingItem)
}
} else if (existingItem.isDeleted) {
val resurrected = existingItem.copy(isDeleted = false, isAvailable = true)
recentFilesRepository.addRecentFile(resurrected)
}
val remoteMeta = folderMetadataMap[bookIdToUse]
if (remoteMeta == null) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
} else {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("Worker: Remote metadata newer for ${file.name}")
val updatedItem = existingItem.copy(
lastChapterIndex = remoteMeta.lastChapterIndex, lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage, lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi, lastPositionCfi = remoteMeta.lastPositionCfi,
@ -234,10 +170,12 @@ class FolderSyncWorker(
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp timestamp = remoteMeta.lastModifiedTimestamp
) )
recentFilesRepository.addRecentFile(updatedItem)
} else if (existingItem.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse)
} }
recentFilesRepository.addRecentFile(itemToUpdate)
if (remoteMeta == null || itemToUpdate.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(stableId)
} }
} }
} }
@ -249,79 +187,6 @@ class FolderSyncWorker(
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(idsToRemove) recentFilesRepository.deleteFilePermanently(idsToRemove)
} }
val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) }
if (orphanedMetadataIds.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.")
try {
val docTree = DocumentFile.fromTreeUri(appContext, folderUri)
val syncDir = docTree?.findFile("episteme")
if (syncDir != null) {
val allFiles = syncDir.listFiles()
orphanedMetadataIds.forEach { orphanId ->
allFiles.filter {
val name = it.name ?: ""
name.contains(orphanId) && (name.endsWith(".json") || name.contains(".sync-conflict"))
}.forEach { fileToDelete ->
try {
fileToDelete.delete()
} catch (_: Exception) { }
}
}
}
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during orphan cleanup")
}
}
}
// Reconcile Metadata (Write-back)
val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val booksToDelete = mutableListOf<String>()
for (localBook in activeDbBooks) {
val remoteMeta = folderMetadataMap[localBook.bookId]
if (remoteMeta == null) {
val exists = try {
val uri = localBook.uriString?.toUri()
if (uri != null) {
DocumentFile.fromSingleUri(appContext, uri)?.exists() == true
} else false
} catch (e: Exception) { false }
if (exists) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
} else {
Timber.tag("FolderSync").i("Metadata Sync: Book ${localBook.displayName} missing from disk. Scheduling removal.")
booksToDelete.add(localBook.bookId)
}
} else {
if (remoteMeta.lastModifiedTimestamp > localBook.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("SyncDecision: Remote NEWER for ${localBook.displayName}. Updating local DB.")
val updatedItem = localBook.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
recentFilesRepository.addRecentFile(updatedItem)
} else if (localBook.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId)
}
}
}
if (booksToDelete.isNotEmpty()) {
recentFilesRepository.deleteFilePermanently(booksToDelete)
} }
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) } prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
@ -335,6 +200,7 @@ class FolderSyncWorker(
) )
return Result.success() return Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
return Result.failure() return Result.failure()

View file

@ -1140,17 +1140,17 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
@Composable @Composable
private fun FolderMigrationDialog(onConfirm: () -> Unit) { private fun FolderMigrationDialog(onConfirm: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = { }, // Force acknowledgment onDismissRequest = { },
icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) }, icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) },
title = { Text("Folder Sync Refactored") }, title = { Text("Folder Sync Update") },
text = { text = {
Column { Column {
Text( Text(
"We've completely rebuilt how Folder Sync works! Books from the folder are now read directly from your folder instead of being copied to app storage." "We've improved Folder Sync! Books are now read directly from your folder without duplicating files."
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
"We'll now perform a one-time scan to migrate your existing progress and bookmarks. This will also free up internal storage space on your device.", "To keep your reading progress safe, your previously synced books have been converted to standard local books. You may see duplicates once the folder resyncs; you can safely delete the old copies at your convenience.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -1158,7 +1158,7 @@ private fun FolderMigrationDialog(onConfirm: () -> Unit) {
}, },
confirmButton = { confirmButton = {
TextButton(onClick = onConfirm) { TextButton(onClick = onConfirm) {
Text("Start Migration") Text("Got it")
} }
} }
) )

View file

@ -529,9 +529,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remoteConfigRepository.init() remoteConfigRepository.init()
val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
if (_internalState.value.syncedFolderUri != null) { if (_internalState.value.syncedFolderUri != null) {
if (isMigrationCompleted) {
Timber.d("App Start: Triggering local folder metadata-only sync.") Timber.d("App Start: Triggering local folder metadata-only sync.")
syncFolderMetadata() syncFolderMetadata()
} else {
Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.")
}
} }
viewModelScope.launch { billingClientWrapper.initializeConnection() } viewModelScope.launch { billingClientWrapper.initializeConnection() }
@ -593,12 +599,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun completeFolderMigration() { fun completeFolderMigration() {
Timber.tag("FolderSync").d("User accepted migration. Marking completed and starting scan.") Timber.tag("FolderSync").d("User acknowledged update. Detaching old books and starting fresh scan.")
viewModelScope.launch {
recentFilesRepository.detachAllFolderBooks()
prefs.edit { putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) } prefs.edit { putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) }
_internalState.update { it.copy(showFolderMigrationDialog = false) } _internalState.update { it.copy(showFolderMigrationDialog = false) }
scanSyncedFolder() scanSyncedFolder()
} }
}
private val fontsRepository = FontsRepository(appContext) private val fontsRepository = FontsRepository(appContext)
@ -1330,8 +1341,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (workInfo != null) { if (workInfo != null) {
when (workInfo.state) { when (workInfo.state) {
WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> {
val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Folder Sync: Scanning files..." val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Scanning folder for new books..."
_internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(msg)) } _internalState.update { it.copy(
isLoading = false,
isRefreshing = true,
bannerMessage = BannerMessage(msg)
) }
} }
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
_internalState.update { it.copy( _internalState.update { it.copy(
@ -1342,7 +1357,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) } ) }
} }
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
_internalState.update { it.copy(isLoading = false, isRefreshing = false, errorMessage = "Sync failed.") } // ADD isRefreshing = false _internalState.update { it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = "Sync failed."
) }
} }
else -> Unit else -> Unit
} }
@ -2255,9 +2274,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (type == FileType.PDF) { if (type == FileType.PDF) {
viewModelScope.launch { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
Timber.d(
"openBook: Loading PDF. bookId=$bookId, initialBookmarksJson from DB: ${recentItem?.bookmarksJson}" if (recentItem?.sourceFolderUri != null) {
) launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.d("openBook: Loading PDF. bookId=$bookId ...")
_internalState.update { _internalState.update {
it.copy( it.copy(
selectedPdfUri = uri, selectedPdfUri = uri,
@ -2278,6 +2302,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
viewModelScope.launch { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
val locator = val locator =
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
Locator( Locator(

View file

@ -90,7 +90,6 @@ class MetadataExtractionWorker(
} }
} }
// Only update if we actually found something useful
if (coverPath != null || title != null || author != null) { if (coverPath != null || title != null || author != null) {
val updatedItem = item.copy( val updatedItem = item.copy(
coverImagePath = coverPath ?: item.coverImagePath, coverImagePath = coverPath ?: item.coverImagePath,
@ -99,6 +98,13 @@ class MetadataExtractionWorker(
) )
recentFilesRepository.addRecentFile(updatedItem) recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}") Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}")
try {
recentFilesRepository.syncLocalMetadataToFolder(updatedItem.bookId)
Timber.tag("MetadataWorker").d("Created/Updated JSON for: ${item.displayName}")
} catch (_: Exception) {
Timber.tag("MetadataWorker").w("Failed to save JSON during extraction for ${item.displayName}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -87,4 +87,7 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0") @Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0")
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity> suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
suspend fun detachAllFolderBooks()
} }

View file

@ -172,6 +172,11 @@ class RecentFilesRepository(private val context: Context) {
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
} }
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
recentFileDao.detachAllFolderBooks()
Timber.d("Detached all folder books. They are now standard local files.")
}
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) { suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime) recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)