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:
parent
0b7bb53a28
commit
7e28c1c2b3
5 changed files with 464 additions and 57 deletions
|
|
@ -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) ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue