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
|
|
@ -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<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) {
|
||||
Timber.tag("FolderSync").d("Phase 2: Scanning physical files...")
|
||||
val currentDiskFiles = mutableListOf<DocumentFile>()
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,44 +38,57 @@ 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 (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)
|
||||
@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) {
|
||||
android.media.MediaScannerConnection.scanFile(
|
||||
context,
|
||||
|
|
@ -85,11 +97,9 @@ object LocalSyncUtils {
|
|||
null
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("Saved hidden metadata for ${metadata.bookId}")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}")
|
||||
} 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,17 +367,24 @@ 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")
|
||||
if (name.contains(".sync-conflict")) {
|
||||
name.substringBefore(".sync-conflict")
|
||||
} else {
|
||||
name.substringBefore(".json")
|
||||
}
|
||||
}
|
||||
|
||||
groupedFiles.forEach { (bookId, files) ->
|
||||
val winner = resolveAndCleanConflicts(context, files, bookId)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2255,7 +2255,15 @@ fun PdfViewerScreen(
|
|||
flatTableOfContents = emptyList()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val oldDoc = pdfDocument
|
||||
val oldPfd = pfdState
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue