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

@ -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<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.
* 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) ->

View file

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