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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue