diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index cad4d0b..7313228 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import androidx.core.content.edit import com.aryan.reader.data.LocalSyncUtils +import java.io.File class FolderSyncWorker( private val appContext: Context, @@ -79,7 +80,6 @@ class FolderSyncWorker( val folderUri = folderUriString.toUri() try { - // Permission checks ... try { appContext.contentResolver.takePersistableUriPermission( folderUri, @@ -94,16 +94,13 @@ class FolderSyncWorker( return Result.failure() } - // PHASE 1: IMPORT METADATA (Always run this to catch updates from other devices) Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) - // Apply updates to local DB folderMetadataMap.forEach { (bookId, remoteMeta) -> val existingItem = recentFilesRepository.getFileByBookId(bookId) if (existingItem != null) { - // Update local if remote is newer if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)") val itemToUpdate = existingItem.copy( @@ -115,17 +112,44 @@ class FolderSyncWorker( locatorBlockIndex = remoteMeta.locatorBlockIndex, locatorCharOffset = remoteMeta.locatorCharOffset, lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, - // Crucial: If remote says it's recent, we make it recent locally isRecent = remoteMeta.isRecent || existingItem.isRecent, timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp ) recentFilesRepository.addRecentFile(itemToUpdate) } } - // We do NOT create new items from JSON alone. We wait for Phase 2 to find the file. } - // PHASE 2: SCAN PHYSICAL FILES (Only if !metadataOnly) + // Phase 1.5: Sync Annotations for existing books (Runs in both Metadata-Only and Full modes) + Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...") + val processedBookIds = mutableSetOf() + val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + + for (book in existingFolderBooks) { + processedBookIds.add(book.bookId) + val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, book.bookId) + + if (sidecarData != null) { + val (remoteTs, jsonPayload) = sidecarData + + // Check timestamps of ALL potential local annotation files + val localFiles = listOf( + File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"), + File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"), + File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"), + File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json") + ) + val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L + + if (remoteTs > (localTs + 1000)) { // 1s buffer + Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.") + recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload) + } else { + Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.") + } + } + } + if (!metadataOnly) { Timber.tag("FolderSync").d("Phase 2: Scanning physical files...") val currentDiskFiles = mutableListOf() @@ -137,11 +161,9 @@ class FolderSyncWorker( val file = fileQueue.removeAt(0) if (file.isDirectory) { - // REMOVED: if (file.name == ".episteme") continue file.listFiles().let { fileQueue.addAll(it) } } else if (file.isFile) { val name = file.name ?: "" - // UPDATED: Ensure we ignore .json files and hidden files during book scan if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) { currentDiskFiles.add(file) } @@ -159,7 +181,6 @@ class FolderSyncWorker( val existingItem = recentFilesRepository.getFileByBookId(stableId) if (existingItem == null) { - // NEW FILE DISCOVERED val remoteMeta = folderMetadataMap[stableId] val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB val placeholderTitle = file.name ?: "Unknown" @@ -169,7 +190,6 @@ class FolderSyncWorker( uriString = file.uri.toString(), type = type, displayName = file.name ?: "Unknown", - // Use remote timestamp if available, else NOW timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), coverImagePath = null, @@ -177,7 +197,6 @@ class FolderSyncWorker( author = remoteMeta?.author, isAvailable = true, isDeleted = false, - // If remote says recent, mark it. If new and no remote data, it is NOT recent. isRecent = remoteMeta?.isRecent ?: false, sourceFolderUri = folderUriString, lastChapterIndex = remoteMeta?.lastChapterIndex, @@ -189,16 +208,33 @@ class FolderSyncWorker( locatorCharOffset = remoteMeta?.locatorCharOffset ) - // Insert. Note: We do NOT call syncLocalMetadataToFolder here. - // It is "Clean" until the user opens it. recentFilesRepository.addRecentFile(newItem) } else { - // EXISTING FILE - Just ensure it is marked available if (existingItem.isDeleted || !existingItem.isAvailable) { val revived = existingItem.copy(isDeleted = false, isAvailable = true) recentFilesRepository.addRecentFile(revived) } - // Metadata updates were already handled in Phase 1 + } + + if (!processedBookIds.contains(stableId)) { + val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, stableId) + + if (sidecarData != null) { + val (remoteTs, jsonPayload) = sidecarData + + val localFiles = listOf( + File(appContext.filesDir, "annotations/annotation_$stableId.json"), + File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"), + File(appContext.filesDir, "page_layouts/layout_$stableId.json"), + File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.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 new book $stableId. Importing.") + recentFilesRepository.importAnnotationBundle(stableId, jsonPayload) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 07e677a..27c3534 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -143,16 +143,9 @@ enum class SortOrder(val displayName: String) { ) } -data class SyncUpdateInfo( - val bookId: String, - val locator: Locator?, - val page: Int?, - val cfi: String?, - val bookmarksJson: String? -) - data class ReaderScreenState( val selectedPdfUri: Uri? = null, + val selectedBookId: String? = null, val selectedEpubBook: EpubBook? = null, val selectedEpubUri: Uri? = null, val selectedFileType: FileType? = null, @@ -1221,6 +1214,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio it.copy( selectedPdfUri = null, selectedEpubUri = null, + selectedBookId = null, selectedEpubBook = null, selectedFileType = null, isLoading = false, @@ -1240,9 +1234,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } if (it.sourceFolderUri != null) { - Timber.d("Book closed (Folder Linked), syncing metadata to folder: ${it.bookId}") + Timber.tag("FolderAnnotationSync").d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}") viewModelScope.launch { recentFilesRepository.syncLocalMetadataToFolder(it.bookId) + recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) } } } @@ -2266,6 +2261,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio it.copy( selectedPdfUri = null, selectedEpubUri = null, + selectedBookId = bookId, selectedEpubBook = null, selectedFileType = type, isLoading = true, @@ -3202,6 +3198,71 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("ViewModel instance cleared (onCleared).") } + suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) = withContext(Dispatchers.IO) { + if (legacyId == newId) return@withContext + Timber.tag("FolderAnnotationSync").d("Checking migration from legacyId=$legacyId to newId=$newId") + + try { + fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { + if (legacyFile != null && legacyFile.exists()) { + if (newFile != null) { + if (newFile.exists()) { + val legacyTs = legacyFile.lastModified() + val newTs = newFile.lastModified() + + if (newTs > legacyTs) { + Timber.tag("FolderAnnotationSync").i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.") + legacyFile.delete() + return + } else { + newFile.delete() + } + } + + if (legacyFile.renameTo(newFile)) { + Timber.tag("FolderAnnotationSync").i("Migrated $tag successfully.") + } else { + Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.") + } + } else { + Timber.tag("FolderAnnotationSync").w("Destination file for $tag is null. Skipping.") + } + } + } + + // 1. Annotations + safeMigrate( + pdfAnnotationRepository.getAnnotationFileForSync(legacyId), + pdfAnnotationRepository.getAnnotationFileForSync(newId), + "annotations" + ) + + // 2. Rich Text + safeMigrate( + pdfRichTextRepository.getFileForSync(legacyId), + pdfRichTextRepository.getFileForSync(newId), + "rich text" + ) + + // 3. Layout + safeMigrate( + pageLayoutRepository.getLayoutFile(legacyId), + pageLayoutRepository.getLayoutFile(newId), + "layout" + ) + + // 4. Text Boxes + safeMigrate( + pdfTextBoxRepository.getFileForSync(legacyId), + pdfTextBoxRepository.getFileForSync(newId), + "text boxes" + ) + + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error migrating legacy book data") + } + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index 38faeb1..f661599 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -8,11 +8,12 @@ import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import timber.log.Timber object LocalSyncUtils { - // REMOVED: private const val SYNC_DIR_NAME = "episteme" private const val TAG = "FolderSync" + private const val ANNOTATION_SUFFIX = "_annotations" suspend fun saveMetadataToFolder( context: Context, @@ -22,13 +23,11 @@ object LocalSyncUtils { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext - // CHANGED: Primary filename now starts with a dot val syncFileName = ".${metadata.bookId}.json" val legacyVisibleName = "${metadata.bookId}.json" val existingHidden = rootTree.findFile(syncFileName) val existingVisible = rootTree.findFile(legacyVisibleName) - val fileToCheck = existingHidden ?: existingVisible if (fileToCheck != null && fileToCheck.exists()) { @@ -39,57 +38,68 @@ object LocalSyncUtils { if (existingContent != null) { val existingMeta = FolderBookMetadata.fromJsonString(existingContent) if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { - Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data.") + Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") return@withContext } } } catch (_: Exception) {} } - var targetFile = existingHidden - - if (targetFile == null) { - // Migrate: If a visible one existed, we'll replace it with hidden - if (existingVisible != null && existingVisible.exists()) { - try { existingVisible.delete() } catch (_: Exception) {} - } - targetFile = rootTree.createFile("application/json", syncFileName) + if (existingVisible != null && existingVisible.exists()) { + try { existingVisible.delete() } catch (_: Exception) {} } - if (targetFile == null) { - Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}") + val tempFileName = ".${metadata.bookId}.tmp" + rootTree.findFile(tempFileName)?.delete() + + val tempFile = rootTree.createFile("application/json", tempFileName) + if (tempFile == null) { + Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") return@withContext } val jsonString = metadata.toJsonString() + var writeSuccess = false try { - context.contentResolver.openFileDescriptor(targetFile.uri, "rwt")?.use { pfd -> + context.contentResolver.openFileDescriptor(tempFile.uri, "rwt")?.use { pfd -> java.io.FileOutputStream(pfd.fileDescriptor).use { fos -> fos.write(jsonString.toByteArray()) fos.flush() try { pfd.fileDescriptor.sync() } catch (_: Exception) { - Timber.tag(TAG).w("FileDescriptor sync not supported on this device/filesystem") } } } + writeSuccess = true + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to write temp metadata for ${metadata.bookId}") + try { tempFile.delete() } catch (_: Exception) {} + return@withContext + } - val absolutePath = getPathFromUri(context, targetFile.uri) - if (absolutePath != null) { - android.media.MediaScannerConnection.scanFile( - context, - arrayOf(absolutePath), - arrayOf("application/json"), - null - ) + @Suppress("KotlinConstantConditions") if (writeSuccess) { + val targetFile = rootTree.findFile(syncFileName) + if (targetFile != null && targetFile.exists()) { + targetFile.delete() } - Timber.tag(TAG).d("Saved hidden metadata for ${metadata.bookId}") + if (tempFile.renameTo(syncFileName)) { + Timber.tag(TAG).d("Atomic save successful: $syncFileName") - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}") + val absolutePath = getPathFromUri(context, tempFile.uri) + if (absolutePath != null) { + android.media.MediaScannerConnection.scanFile( + context, + arrayOf(absolutePath), + arrayOf("application/json"), + null + ) + } + } else { + Timber.tag(TAG).e("Failed to rename temp file to $syncFileName") + } } } catch (e: Exception) { @@ -97,6 +107,178 @@ object LocalSyncUtils { } } + suspend fun saveAnnotationSidecar( + context: Context, + sourceFolderUri: Uri, + bookId: String, + jsonPayload: String, + timestamp: Long + ) = withContext(Dispatchers.IO) { + Timber.tag("FolderAnnotationSync").d("saveAnnotationSidecar called for bookId: $bookId, timestamp: $timestamp") + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: run { + Timber.tag("FolderAnnotationSync").w("Could not get DocumentFile from sourceFolderUri") + return@withContext + } + + val currentBest = resolveAndCleanAnnotationConflicts(context, rootTree, bookId) + + if (currentBest != null) { + val (remoteTs, _) = currentBest + if (remoteTs >= timestamp) { + Timber.tag("FolderAnnotationSync").d("AnnotationSync: Remote sidecar (ts=$remoteTs) is newer or same as local (ts=$timestamp). Aborting write.") + return@withContext + } + } + + val wrapper = JSONObject() + wrapper.put("version", 1) + wrapper.put("timestamp", timestamp) + wrapper.put("data", JSONObject(jsonPayload)) + val contentBytes = wrapper.toString().toByteArray() + + val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" + val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" + + rootTree.findFile(tempName)?.delete() + + val tempFile = rootTree.createFile("application/json", tempName) + if (tempFile == null) { + Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") + return@withContext + } + + var writeSuccess = false + try { + context.contentResolver.openFileDescriptor(tempFile.uri, "rwt")?.use { pfd -> + java.io.FileOutputStream(pfd.fileDescriptor).use { fos -> + fos.write(contentBytes) + fos.flush() + try { pfd.fileDescriptor.sync() } catch (_: Exception) {} + } + } + writeSuccess = true + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error writing to temp sidecar.") + try { tempFile.delete() } catch (_: Exception) {} + return@withContext + } + + @Suppress("KotlinConstantConditions") if (writeSuccess) { + val existingMain = rootTree.findFile(targetName) + if (existingMain != null) { + if (!existingMain.delete()) { + Timber.tag("FolderAnnotationSync").w("Failed to delete existing sidecar before rename. Attempting rename anyway (might fail on some SAF providers).") + } + } + + if (tempFile.renameTo(targetName)) { + Timber.tag("FolderAnnotationSync").d("AnnotationSync: Atomic save successful for $targetName") + } else { + Timber.tag("FolderAnnotationSync").e("AnnotationSync: Failed to rename temp sidecar to $targetName") + } + } + + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Failed to save annotation sidecar for $bookId") + } + } + + suspend fun getAnnotationSidecar( + context: Context, + sourceFolderUri: Uri, + bookId: String + ): Pair? = withContext(Dispatchers.IO) { + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null + + val bestFile = resolveAndCleanAnnotationConflicts(context, rootTree, bookId) + return@withContext bestFile + + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to read annotation sidecar for $bookId") + } + return@withContext null + } + + private fun resolveAndCleanAnnotationConflicts( + context: Context, + rootTree: DocumentFile, + bookId: String + ): Pair? { + val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" + + val allFiles = rootTree.listFiles() + + val candidates = allFiles.filter { file -> + val name = file.name ?: "" + name.startsWith(basePattern) && + name.endsWith(".json") && + !name.endsWith(".tmp") && + !name.contains(".syncthing.") + } + + if (candidates.isEmpty()) return null + + var bestTs = -1L + var bestData: String? = null + var bestFile: DocumentFile? = null + val filesToDelete = mutableListOf() + + for (file in candidates) { + try { + val content = context.contentResolver.openInputStream(file.uri)?.use { + it.bufferedReader().readText() + } ?: continue + + val json = JSONObject(content) + val ts = json.optLong("timestamp", 0L) + val data = json.optJSONObject("data")?.toString() + + if (data != null) { + if (ts > bestTs) { + if (bestFile != null) filesToDelete.add(bestFile) + + bestTs = ts + bestData = data + bestFile = file + } else { + filesToDelete.add(file) + } + } else { + filesToDelete.add(file) + } + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error parsing candidate file: ${file.name}") + } + } + + if (filesToDelete.isNotEmpty()) { + Timber.tag("FolderAnnotationSync").i("Resolving conflicts for $bookId. Found ${filesToDelete.size} obsolete/conflict files.") + for (toDelete in filesToDelete) { + try { + Timber.tag("FolderAnnotationSync").v("Deleting loser: ${toDelete.name}") + toDelete.delete() + } catch (_: Exception) {} + } + } + + if (bestFile != null) { + val correctName = "${basePattern}.json" + if (bestFile.name != correctName) { + Timber.tag("FolderAnnotationSync").i("Renaming winner ${bestFile.name} to $correctName") + val existingTarget = rootTree.findFile(correctName) + if (existingTarget != null && existingTarget.uri != bestFile.uri) { + existingTarget.delete() + } + bestFile.renameTo(correctName) + } + return Pair(bestTs, bestData!!) + } + + return null + } + /** * Helper to attempt to resolve a SAF URI to an absolute filesystem path. * This is required because MediaScannerConnection does not accept content:// URIs. @@ -185,16 +367,23 @@ object LocalSyncUtils { try { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults - // CHANGED: Scanning rootTree directly val allFiles = rootTree.listFiles() val groupedFiles = allFiles - .filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true } + .filter { + val name = it.name ?: "" + (name.endsWith(".json") || name.contains(".sync-conflict")) && + !name.endsWith(".tmp") && + !name.contains(".syncthing.") + } .groupBy { file -> var name = file.name ?: "" if (name.startsWith(".")) name = name.substring(1) - name = name.substringBefore(".sync-conflict") - name.substringBefore(".json") + if (name.contains(".sync-conflict")) { + name.substringBefore(".sync-conflict") + } else { + name.substringBefore(".json") + } } groupedFiles.forEach { (bookId, files) -> diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index ffd0149..91d8011 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -27,12 +27,18 @@ import androidx.core.net.toUri import timber.log.Timber import com.aryan.reader.BookImporter import com.aryan.reader.paginatedreader.Locator +import com.aryan.reader.pdf.PdfRichTextRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import com.aryan.reader.pdf.data.PdfAnnotationRepository +import com.aryan.reader.pdf.data.PageLayoutRepository +import com.aryan.reader.pdf.data.PdfTextBoxRepository +import org.json.JSONObject +import org.json.JSONArray private const val COVER_CACHE_DIR = "cover_cache" @@ -42,6 +48,11 @@ class RecentFilesRepository(private val context: Context) { private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR) private val bookImporter = BookImporter(context) + private val pdfAnnotationRepository = PdfAnnotationRepository(context) + private val pdfRichTextRepository = PdfRichTextRepository(context) + private val pageLayoutRepository = PageLayoutRepository(context) + private val pdfTextBoxRepository = PdfTextBoxRepository(context) + init { if (!coverCacheDir.exists()) { coverCacheDir.mkdirs() @@ -156,6 +167,108 @@ class RecentFilesRepository(private val context: Context) { } } + suspend fun syncLocalAnnotationsToFolder(bookId: String) = withContext(Dispatchers.IO) { + Timber.tag("FolderAnnotationSync").d("syncLocalAnnotationsToFolder called for bookId: $bookId") + val entity = recentFileDao.getFileByBookId(bookId) ?: run { + Timber.tag("FolderAnnotationSync").w("Entity not found for bookId: $bookId") + return@withContext + } + val folderUriString = entity.sourceFolderUri ?: run { + Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId") + return@withContext + } + + val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) + val richTextFile = pdfRichTextRepository.getFileForSync(bookId) + val layoutFile = pageLayoutRepository.getLayoutFile(bookId) + val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) + + val hasInk = inkFile?.exists() == true + val hasRichText = richTextFile.exists() + val hasLayout = layoutFile.exists() + val hasTextBoxes = textBoxFile.exists() + + Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes") + + if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes) { + Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.") + return@withContext + } + + val bundleJson = JSONObject() + + fun putJsonSafe(key: String, file: File) { + try { + val content = file.readText().trim() + if (content.startsWith("[")) { + bundleJson.put(key, JSONArray(content)) + } else if (content.startsWith("{")) { + bundleJson.put(key, JSONObject(content)) + } + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file") + } + } + + if (hasInk) putJsonSafe("ink", inkFile) + if (hasRichText) putJsonSafe("text", richTextFile) + if (hasLayout) putJsonSafe("layout", layoutFile) + if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile) + + val tsInk = if(hasInk) inkFile.lastModified() else 0L + val tsText = if(hasRichText) richTextFile.lastModified() else 0L + val tsLayout = if(hasLayout) layoutFile.lastModified() else 0L + val tsBox = if(hasTextBoxes) textBoxFile.lastModified() else 0L + + val maxFileTs = maxOf(tsInk, tsText, tsLayout, tsBox) + val finalTs = maxOf(maxFileTs, System.currentTimeMillis()) + + Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs") + + LocalSyncUtils.saveAnnotationSidecar( + context = context, + sourceFolderUri = folderUriString.toUri(), + bookId = bookId, + jsonPayload = bundleJson.toString(), + timestamp = finalTs + ) + } + + suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) { + Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId") + try { + val bundle = JSONObject(jsonString) + + fun writeSafe(key: String, file: File?) { + if (file != null && bundle.has(key)) { + file.parentFile?.mkdirs() + val contentStr = bundle.get(key).toString() + file.writeText(contentStr) + Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)") + } + } + + // 1. Ink + val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File( + context.filesDir, "annotations/annotation_$bookId.json" + ) + writeSafe("ink", inkFile) + + // 2. Text + writeSafe("text", pdfRichTextRepository.getFileForSync(bookId)) + + // 3. Layout + writeSafe("layout", pageLayoutRepository.getLayoutFile(bookId)) + + // 4. Text Boxes + writeSafe("textBoxes", pdfTextBoxRepository.getFileForSync(bookId)) + + Timber.tag("FolderAnnotationSync").i("Successfully imported annotation bundle for $bookId from folder.") + } catch (e: Exception) { + Timber.tag("FolderAnnotationSync").e(e, "Failed to import annotation bundle for $bookId") + } + } + suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) { recentFileDao.deleteFilesBySourceFolder(folderUriString) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index dddda4f..9b82eed 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -2255,7 +2255,15 @@ fun PdfViewerScreen( flatTableOfContents = emptyList() val fastId = getFastFileId(context, pdfUri) - currentBookId = fastId + val selectedId = uiState.selectedBookId + + if (selectedId != null && selectedId != fastId) { + Timber.tag("FolderAnnotationSync").i("Detected ID mismatch. Legacy: $fastId, Selected: $selectedId. Initiating migration.") + viewModel.checkAndMigrateLegacyBookId(fastId, selectedId) + currentBookId = selectedId + } else { + currentBookId = fastId + } val oldDoc = pdfDocument val oldPfd = pfdState