Folder pdf annotation sync (#32)

* Implemented folder-based annotation synchronization and legacy book ID migration.

- Added `syncLocalAnnotationsToFolder` and `importAnnotationBundle` to `RecentFilesRepository` to handle bundling ink, rich text, layouts, and text boxes into a single JSON sidecar file.
- Introduced `saveAnnotationSidecar` and `getAnnotationSidecar` in `LocalSyncUtils` to manage hidden synchronization files in linked folders using SAF.
- Updated `MainViewModel` to trigger annotation syncing on book close and added a migration utility for legacy book IDs.
- Enhanced `FolderSyncWorker` to include a dedicated phase for importing newer annotation bundles from synchronized folders.
- Fixed an ID mismatch edge case in `PdfViewerScreen` by initiating data migration when a legacy ID is detected.
- Added `selectedBookId` to `ReaderScreenState` to better track the active document across the application.

* fix(sync): resolve Syncthing file lock conflicts and annotation migration bug

- Implement atomic saves (write-to-temp then rename) for annotation sidecars in `LocalSyncUtils` to prevent `.tmp` file locking and partial writes when using third-party sync tools like Syncthing.
- Add robust conflict resolution for annotation sidecars (`resolveAndCleanAnnotationConflicts`) to automatically detect, keep the newest version, and clean up `.sync-conflict` files.
- Fix a bug in `MainViewModel.checkAndMigrateLegacyBookId` where older legacy local files would blindly overwrite newly synced annotation files by adding a timestamp-based clobber check.
This commit is contained in:
Aryan 2026-03-05 16:34:22 +05:30 committed by GitHub
parent 0b7bb53a28
commit 7e28c1c2b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 464 additions and 57 deletions

View file

@ -37,6 +37,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.LocalSyncUtils
import java.io.File
class FolderSyncWorker( class FolderSyncWorker(
private val appContext: Context, private val appContext: Context,
@ -79,7 +80,6 @@ class FolderSyncWorker(
val folderUri = folderUriString.toUri() val folderUri = folderUriString.toUri()
try { try {
// Permission checks ...
try { try {
appContext.contentResolver.takePersistableUriPermission( appContext.contentResolver.takePersistableUriPermission(
folderUri, folderUri,
@ -94,16 +94,13 @@ class FolderSyncWorker(
return Result.failure() 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...") Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
// Apply updates to local DB
folderMetadataMap.forEach { (bookId, remoteMeta) -> folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId) val existingItem = recentFilesRepository.getFileByBookId(bookId)
if (existingItem != null) { if (existingItem != null) {
// Update local if remote is newer
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)") Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)")
val itemToUpdate = existingItem.copy( val itemToUpdate = existingItem.copy(
@ -115,17 +112,44 @@ class FolderSyncWorker(
locatorBlockIndex = remoteMeta.locatorBlockIndex, locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset, locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
// Crucial: If remote says it's recent, we make it recent locally
isRecent = remoteMeta.isRecent || existingItem.isRecent, isRecent = remoteMeta.isRecent || existingItem.isRecent,
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
) )
recentFilesRepository.addRecentFile(itemToUpdate) 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<String>()
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) { if (!metadataOnly) {
Timber.tag("FolderSync").d("Phase 2: Scanning physical files...") Timber.tag("FolderSync").d("Phase 2: Scanning physical files...")
val currentDiskFiles = mutableListOf<DocumentFile>() val currentDiskFiles = mutableListOf<DocumentFile>()
@ -137,11 +161,9 @@ class FolderSyncWorker(
val file = fileQueue.removeAt(0) val file = fileQueue.removeAt(0)
if (file.isDirectory) { if (file.isDirectory) {
// REMOVED: if (file.name == ".episteme") continue
file.listFiles().let { fileQueue.addAll(it) } file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) { } else if (file.isFile) {
val name = file.name ?: "" val name = file.name ?: ""
// UPDATED: Ensure we ignore .json files and hidden files during book scan
if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) { if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) {
currentDiskFiles.add(file) currentDiskFiles.add(file)
} }
@ -159,7 +181,6 @@ class FolderSyncWorker(
val existingItem = recentFilesRepository.getFileByBookId(stableId) val existingItem = recentFilesRepository.getFileByBookId(stableId)
if (existingItem == null) { if (existingItem == null) {
// NEW FILE DISCOVERED
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
val placeholderTitle = file.name ?: "Unknown" val placeholderTitle = file.name ?: "Unknown"
@ -169,7 +190,6 @@ class FolderSyncWorker(
uriString = file.uri.toString(), uriString = file.uri.toString(),
type = type, type = type,
displayName = file.name ?: "Unknown", displayName = file.name ?: "Unknown",
// Use remote timestamp if available, else NOW
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
coverImagePath = null, coverImagePath = null,
@ -177,7 +197,6 @@ class FolderSyncWorker(
author = remoteMeta?.author, author = remoteMeta?.author,
isAvailable = true, isAvailable = true,
isDeleted = false, isDeleted = false,
// If remote says recent, mark it. If new and no remote data, it is NOT recent.
isRecent = remoteMeta?.isRecent ?: false, isRecent = remoteMeta?.isRecent ?: false,
sourceFolderUri = folderUriString, sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta?.lastChapterIndex, lastChapterIndex = remoteMeta?.lastChapterIndex,
@ -189,16 +208,33 @@ class FolderSyncWorker(
locatorCharOffset = remoteMeta?.locatorCharOffset locatorCharOffset = remoteMeta?.locatorCharOffset
) )
// Insert. Note: We do NOT call syncLocalMetadataToFolder here.
// It is "Clean" until the user opens it.
recentFilesRepository.addRecentFile(newItem) recentFilesRepository.addRecentFile(newItem)
} else { } else {
// EXISTING FILE - Just ensure it is marked available
if (existingItem.isDeleted || !existingItem.isAvailable) { if (existingItem.isDeleted || !existingItem.isAvailable) {
val revived = existingItem.copy(isDeleted = false, isAvailable = true) val revived = existingItem.copy(isDeleted = false, isAvailable = true)
recentFilesRepository.addRecentFile(revived) 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)
}
}
} }
} }

View file

@ -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( data class ReaderScreenState(
val selectedPdfUri: Uri? = null, val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
val selectedEpubBook: EpubBook? = null, val selectedEpubBook: EpubBook? = null,
val selectedEpubUri: Uri? = null, val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null, val selectedFileType: FileType? = null,
@ -1221,6 +1214,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy( it.copy(
selectedPdfUri = null, selectedPdfUri = null,
selectedEpubUri = null, selectedEpubUri = null,
selectedBookId = null,
selectedEpubBook = null, selectedEpubBook = null,
selectedFileType = null, selectedFileType = null,
isLoading = false, isLoading = false,
@ -1240,9 +1234,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
if (it.sourceFolderUri != null) { 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 { viewModelScope.launch {
recentFilesRepository.syncLocalMetadataToFolder(it.bookId) recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId)
} }
} }
} }
@ -2266,6 +2261,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy( it.copy(
selectedPdfUri = null, selectedPdfUri = null,
selectedEpubUri = null, selectedEpubUri = null,
selectedBookId = bookId,
selectedEpubBook = null, selectedEpubBook = null,
selectedFileType = type, selectedFileType = type,
isLoading = true, isLoading = true,
@ -3202,6 +3198,71 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("ViewModel instance cleared (onCleared).") 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 { companion object {
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names" internal const val KEY_SHELVES = "shelf_names"

View file

@ -8,11 +8,12 @@ import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
object LocalSyncUtils { object LocalSyncUtils {
// REMOVED: private const val SYNC_DIR_NAME = "episteme"
private const val TAG = "FolderSync" private const val TAG = "FolderSync"
private const val ANNOTATION_SUFFIX = "_annotations"
suspend fun saveMetadataToFolder( suspend fun saveMetadataToFolder(
context: Context, context: Context,
@ -22,13 +23,11 @@ object LocalSyncUtils {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
// CHANGED: Primary filename now starts with a dot
val syncFileName = ".${metadata.bookId}.json" val syncFileName = ".${metadata.bookId}.json"
val legacyVisibleName = "${metadata.bookId}.json" val legacyVisibleName = "${metadata.bookId}.json"
val existingHidden = rootTree.findFile(syncFileName) val existingHidden = rootTree.findFile(syncFileName)
val existingVisible = rootTree.findFile(legacyVisibleName) val existingVisible = rootTree.findFile(legacyVisibleName)
val fileToCheck = existingHidden ?: existingVisible val fileToCheck = existingHidden ?: existingVisible
if (fileToCheck != null && fileToCheck.exists()) { if (fileToCheck != null && fileToCheck.exists()) {
@ -39,44 +38,57 @@ object LocalSyncUtils {
if (existingContent != null) { if (existingContent != null) {
val existingMeta = FolderBookMetadata.fromJsonString(existingContent) val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { 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 return@withContext
} }
} }
} catch (_: Exception) {} } 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()) { if (existingVisible != null && existingVisible.exists()) {
try { existingVisible.delete() } catch (_: Exception) {} try { existingVisible.delete() } catch (_: Exception) {}
} }
targetFile = rootTree.createFile("application/json", syncFileName)
}
if (targetFile == null) { val tempFileName = ".${metadata.bookId}.tmp"
Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}") 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 return@withContext
} }
val jsonString = metadata.toJsonString() val jsonString = metadata.toJsonString()
var writeSuccess = false
try { try {
context.contentResolver.openFileDescriptor(targetFile.uri, "rwt")?.use { pfd -> context.contentResolver.openFileDescriptor(tempFile.uri, "rwt")?.use { pfd ->
java.io.FileOutputStream(pfd.fileDescriptor).use { fos -> java.io.FileOutputStream(pfd.fileDescriptor).use { fos ->
fos.write(jsonString.toByteArray()) fos.write(jsonString.toByteArray())
fos.flush() fos.flush()
try { try {
pfd.fileDescriptor.sync() pfd.fileDescriptor.sync()
} catch (_: Exception) { } 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) @Suppress("KotlinConstantConditions") if (writeSuccess) {
val targetFile = rootTree.findFile(syncFileName)
if (targetFile != null && targetFile.exists()) {
targetFile.delete()
}
if (tempFile.renameTo(syncFileName)) {
Timber.tag(TAG).d("Atomic save successful: $syncFileName")
val absolutePath = getPathFromUri(context, tempFile.uri)
if (absolutePath != null) { if (absolutePath != null) {
android.media.MediaScannerConnection.scanFile( android.media.MediaScannerConnection.scanFile(
context, context,
@ -85,11 +97,9 @@ object LocalSyncUtils {
null null
) )
} }
} else {
Timber.tag(TAG).d("Saved hidden metadata for ${metadata.bookId}") Timber.tag(TAG).e("Failed to rename temp file to $syncFileName")
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}")
} }
} catch (e: Exception) { } 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<Long, String>? = 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<Long, String>? {
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<DocumentFile>()
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. * Helper to attempt to resolve a SAF URI to an absolute filesystem path.
* This is required because MediaScannerConnection does not accept content:// URIs. * This is required because MediaScannerConnection does not accept content:// URIs.
@ -185,17 +367,24 @@ object LocalSyncUtils {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
// CHANGED: Scanning rootTree directly
val allFiles = rootTree.listFiles() val allFiles = rootTree.listFiles()
val groupedFiles = allFiles 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 -> .groupBy { file ->
var name = file.name ?: "" var name = file.name ?: ""
if (name.startsWith(".")) name = name.substring(1) if (name.startsWith(".")) name = name.substring(1)
name = name.substringBefore(".sync-conflict") if (name.contains(".sync-conflict")) {
name.substringBefore(".sync-conflict")
} else {
name.substringBefore(".json") name.substringBefore(".json")
} }
}
groupedFiles.forEach { (bookId, files) -> groupedFiles.forEach { (bookId, files) ->
val winner = resolveAndCleanConflicts(context, files, bookId) val winner = resolveAndCleanConflicts(context, files, bookId)

View file

@ -27,12 +27,18 @@ import androidx.core.net.toUri
import timber.log.Timber import timber.log.Timber
import com.aryan.reader.BookImporter import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.pdf.PdfRichTextRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream 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" 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 coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
private val bookImporter = BookImporter(context) 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 { init {
if (!coverCacheDir.exists()) { if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs() 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) { suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
recentFileDao.deleteFilesBySourceFolder(folderUriString) recentFileDao.deleteFilesBySourceFolder(folderUriString)
} }

View file

@ -2255,7 +2255,15 @@ fun PdfViewerScreen(
flatTableOfContents = emptyList() flatTableOfContents = emptyList()
val fastId = getFastFileId(context, pdfUri) val fastId = getFastFileId(context, pdfUri)
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 currentBookId = fastId
}
val oldDoc = pdfDocument val oldDoc = pdfDocument
val oldPfd = pfdState val oldPfd = pfdState