Folder sync patch (#22)

* Refactored folder synchronization logic and improved migration handling

- Updated `MainViewModel` to skip synchronization until folder migration is completed and added metadata syncing when opening books.
- Modified folder migration to detach existing folder books (converting them to standard local files) before starting a fresh scan.
- Added `detachAllFolderBooks` to `RecentFileDao` and `RecentFilesRepository` to facilitate the migration process.
- Updated `MetadataExtractionWorker` to sync local metadata to the folder immediately after extraction.
- Simplified `FolderSyncWorker` by removing legacy migration lookups and streamlining the reconciliation of local and remote metadata.
- Updated `FolderMigrationDialog` UI text to reflect that books are now read directly from folders without duplication.
- Fixed various indentation and formatting issues across several files.

* Removed the `.episteme` sync directory and moved book metadata files directly to the folder root as hidden files.
This commit is contained in:
Aryan 2026-03-02 13:15:34 +05:30 committed by GitHub
parent 0885b6949e
commit 7d26e10c07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 195 additions and 257 deletions

View file

@ -21,7 +21,6 @@
package com.aryan.reader package com.aryan.reader
import android.content.Context import android.content.Context
import android.net.Uri
import timber.log.Timber import timber.log.Timber
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@ -32,9 +31,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -48,10 +44,6 @@ class FolderSyncWorker(
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val bookImporter = BookImporter(appContext)
companion object { companion object {
const val WORK_NAME = "FolderSyncWorker" const val WORK_NAME = "FolderSyncWorker"
@ -62,6 +54,13 @@ class FolderSyncWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
// Check if folder is still linked before starting
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
Timber.tag("FolderSync").w("Worker: Folder unlinked. Aborting work.")
return Result.success()
}
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...") Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@ -80,6 +79,7 @@ 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,21 +94,55 @@ 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...")
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) 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(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
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)
if (!metadataOnly) { if (!metadataOnly) {
Timber.tag("FolderSync").d("Phase 2: Scanning physical files...")
val currentDiskFiles = mutableListOf<DocumentFile>() val currentDiskFiles = mutableListOf<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>() val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) } documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) { while (fileQueue.isNotEmpty()) {
if (isStopped) break
val file = fileQueue.removeAt(0) val file = fileQueue.removeAt(0)
if (file.isDirectory) { if (file.isDirectory) {
if (file.name == ".episteme") continue // 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 ?: ""
if (isValidExtension(name)) { // UPDATED: Ensure we ignore .json files and hidden files during book scan
if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) {
currentDiskFiles.add(file) currentDiskFiles.add(file)
} }
} }
@ -117,12 +151,15 @@ class FolderSyncWorker(
val foundBookIds = mutableSetOf<String>() val foundBookIds = mutableSetOf<String>()
for (file in currentDiskFiles) { for (file in currentDiskFiles) {
if (isStopped) break
val stableId = "local_${file.name}_${file.length()}" val stableId = "local_${file.name}_${file.length()}"
foundBookIds.add(stableId) foundBookIds.add(stableId)
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"
@ -132,6 +169,7 @@ 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,
@ -139,7 +177,8 @@ class FolderSyncWorker(
author = remoteMeta?.author, author = remoteMeta?.author,
isAvailable = true, isAvailable = true,
isDeleted = false, isDeleted = false,
isRecent = false, // If remote says recent, mark it. If new and no remote data, it is NOT recent.
isRecent = remoteMeta?.isRecent ?: false,
sourceFolderUri = folderUriString, sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta?.lastChapterIndex, lastChapterIndex = remoteMeta?.lastChapterIndex,
lastPage = remoteMeta?.lastPage, lastPage = remoteMeta?.lastPage,
@ -149,55 +188,43 @@ class FolderSyncWorker(
locatorBlockIndex = remoteMeta?.locatorBlockIndex, locatorBlockIndex = remoteMeta?.locatorBlockIndex,
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 {
var itemToUpdate = existingItem // EXISTING FILE - Just ensure it is marked available
if (existingItem.isDeleted || !existingItem.isAvailable) {
if (existingItem.isDeleted) { val revived = existingItem.copy(isDeleted = false, isAvailable = true)
itemToUpdate = itemToUpdate.copy(isDeleted = false, isAvailable = true) recentFilesRepository.addRecentFile(revived)
}
val remoteMeta = folderMetadataMap[stableId]
if (remoteMeta != null && remoteMeta.lastModifiedTimestamp > itemToUpdate.lastModifiedTimestamp) {
itemToUpdate = itemToUpdate.copy(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
lastPositionCfi = remoteMeta.lastPositionCfi,
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson,
locatorBlockIndex = remoteMeta.locatorBlockIndex,
locatorCharOffset = remoteMeta.locatorCharOffset,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp,
timestamp = remoteMeta.lastModifiedTimestamp
)
}
recentFilesRepository.addRecentFile(itemToUpdate)
if (remoteMeta == null || itemToUpdate.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
recentFilesRepository.syncLocalMetadataToFolder(stableId)
} }
// Metadata updates were already handled in Phase 1
} }
} }
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) // Cleanup removed files
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } if (!isStopped) {
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
if (idsToRemove.isNotEmpty()) { if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(idsToRemove) recentFilesRepository.deleteFilePermanently(idsToRemove)
}
} }
} }
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) } prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") if (!isStopped) {
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build() Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
WorkManager.getInstance(appContext).enqueueUniqueWork( val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
MetadataExtractionWorker.WORK_NAME, WorkManager.getInstance(appContext).enqueueUniqueWork(
ExistingWorkPolicy.APPEND_OR_REPLACE, MetadataExtractionWorker.WORK_NAME,
metaRequest ExistingWorkPolicy.APPEND_OR_REPLACE,
) metaRequest
)
}
return Result.success() return Result.success()
@ -207,53 +234,6 @@ class FolderSyncWorker(
} }
} }
private data class ExtractedInfo(
val title: String? = null,
val author: String? = null,
val coverPath: String? = null
)
private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
var coverPath: String? = null
var title: String? = null
var author: String? = null
try {
if (type == FileType.EPUB || type == FileType.MOBI) {
val book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
if (type == FileType.EPUB) {
epubParser.createEpubBook(
inputStream = inputStream,
originalBookNameHint = displayName,
parseContent = false
)
} else {
mobiParser.createMobiBook(
inputStream = inputStream,
originalBookNameHint = displayName
)
}
}
}
if (book != null) {
title = book.title.takeIf { it.isNotBlank() }
author = book.author.takeIf { it.isNotBlank() }
book.coverImage?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} else if (type == FileType.PDF) {
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to extract info for file: $displayName")
}
return ExtractedInfo(title, author, coverPath)
}
private fun isValidExtension(name: String): Boolean { private fun isValidExtension(name: String): Boolean {
return name.endsWith(".pdf", true) || return name.endsWith(".pdf", true) ||
name.endsWith(".epub", true) || name.endsWith(".epub", true) ||

View file

@ -1373,9 +1373,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun disconnectSyncedFolder() { fun disconnectSyncedFolder() {
viewModelScope.launch { viewModelScope.launch {
val folderUriString = _internalState.value.syncedFolderUri val folderUriString = _internalState.value.syncedFolderUri
Timber.tag("FolderSync").d("Cancelling all folder sync workers...")
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
WorkManager.getInstance(appContext).cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
prefs.edit {
remove(KEY_SYNCED_FOLDER_URI)
remove(KEY_LAST_FOLDER_SCAN_TIME)
}
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
if (folderUriString != null) { if (folderUriString != null) {
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.") Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call recentFilesRepository.deleteFilesBySourceFolder(folderUriString)
try { try {
val uri = folderUriString.toUri() val uri = folderUriString.toUri()
@ -1387,14 +1399,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.e(e, "Failed to release permission") Timber.e(e, "Failed to release permission")
} }
} }
prefs.edit {
remove(KEY_SYNCED_FOLDER_URI)
remove(KEY_LAST_FOLDER_SCAN_TIME)
}
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
} }
} }
@ -3100,14 +3104,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try { try {
val rootUri = item.sourceFolderUri.toUri() val rootUri = item.sourceFolderUri.toUri()
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri) val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme")
if (syncDir != null) { if (rootDoc != null) {
// Try hidden first, then legacy val hiddenMeta = rootDoc.findFile(".${item.bookId}.json")
val metaFile = syncDir.findFile(".${item.bookId}.json") val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json")
?: syncDir.findFile("${item.bookId}.json")
metaFile?.delete() hiddenMeta?.delete()
legacyVisibleMeta?.delete()
Timber.tag("FolderSync").d("Deleted metadata for ${item.bookId} from root.")
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error deleting metadata file for ${item.bookId}") Timber.e(e, "Error deleting metadata file for ${item.bookId}")

View file

@ -28,8 +28,13 @@ class MetadataExtractionWorker(
} }
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
Timber.tag("MetadataWorker").w("Folder disconnected. Stopping extraction worker.")
return@withContext Result.success()
}
try { try {
// Fetch all books that are from a folder but don't have a cover yet (implies metadata likely missing/basic)
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers() val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
if (filesToProcess.isEmpty()) { if (filesToProcess.isEmpty()) {
@ -41,6 +46,8 @@ class MetadataExtractionWorker(
filesToProcess.forEach { item -> filesToProcess.forEach { item ->
if (isStopped) return@forEach if (isStopped) return@forEach
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach
try { try {
val uri = item.uriString?.toUri() ?: return@forEach val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type val type = item.type
@ -49,7 +56,6 @@ class MetadataExtractionWorker(
var title: String? = null var title: String? = null
var author: String? = null var author: String? = null
// We open the stream briefly to extract metadata
appContext.contentResolver.openInputStream(uri)?.use { inputStream -> appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
when (type) { when (type) {
FileType.EPUB -> { FileType.EPUB -> {
@ -58,35 +64,25 @@ class MetadataExtractionWorker(
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
parseContent = false parseContent = false
) )
book.let { title = book.title.takeIf { it.isNotBlank() }
title = it.title.takeIf { t -> t.isNotBlank() } author = book.author.takeIf { it.isNotBlank() }
author = it.author.takeIf { a -> a.isNotBlank() } book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
it.coverImage?.let { img ->
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
}
}
} }
FileType.MOBI -> { FileType.MOBI -> {
val book = mobiParser.createMobiBook( val book = mobiParser.createMobiBook(inputStream, item.displayName)
inputStream = inputStream,
originalBookNameHint = item.displayName
)
book?.let { book?.let {
title = it.title.takeIf { t -> t.isNotBlank() } title = it.title.takeIf { t -> t.isNotBlank() }
author = it.author.takeIf { a -> a.isNotBlank() } author = it.author.takeIf { a -> a.isNotBlank() }
it.coverImage?.let { img -> it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
}
} }
} }
FileType.PDF -> { FileType.PDF -> {
// PDF cover generation is heavy, but necessary
pdfCoverGenerator.generateCover(uri)?.let { pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri) coverPath = recentFilesRepository.saveCoverToCache(it, uri)
} }
title = item.displayName.substringBeforeLast(".") // Clean filename title = item.displayName.substringBeforeLast(".")
} }
else -> { /* Text/MD files usually don't have covers */ } else -> {}
} }
} }
@ -97,14 +93,8 @@ class MetadataExtractionWorker(
author = author ?: item.author author = author ?: item.author
) )
recentFilesRepository.addRecentFile(updatedItem) recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}") Timber.tag("MetadataWorker").d("Updated local metadata for: ${item.displayName}")
try {
recentFilesRepository.syncLocalMetadataToFolder(updatedItem.bookId)
Timber.tag("MetadataWorker").d("Created/Updated JSON for: ${item.displayName}")
} catch (_: Exception) {
Timber.tag("MetadataWorker").w("Failed to save JSON during extraction for ${item.displayName}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -3,13 +3,15 @@ package com.aryan.reader.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment
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 timber.log.Timber import timber.log.Timber
object LocalSyncUtils { object LocalSyncUtils {
private const val SYNC_DIR_NAME = "episteme" // REMOVED: private const val SYNC_DIR_NAME = "episteme"
private const val TAG = "FolderSync" private const val TAG = "FolderSync"
suspend fun saveMetadataToFolder( suspend fun saveMetadataToFolder(
@ -20,62 +22,41 @@ object LocalSyncUtils {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = getOrCreateSyncDir(rootTree) // CHANGED: Primary filename now starts with a dot
val syncFileName = ".${metadata.bookId}.json"
val legacyVisibleName = "${metadata.bookId}.json"
if (syncDir == null) { val existingHidden = rootTree.findFile(syncFileName)
Timber.tag(TAG).e("Could not create/find $SYNC_DIR_NAME directory in $sourceFolderUri") val existingVisible = rootTree.findFile(legacyVisibleName)
return@withContext
}
// Ensure .nomedia exists to prevent gallery clutter val fileToCheck = existingHidden ?: existingVisible
ensureNoMedia(syncDir)
// Use hidden filename to avoid "Recents" clutter if (fileToCheck != null && fileToCheck.exists()) {
val hiddenFileName = ".${metadata.bookId}.json"
val legacyFileName = "${metadata.bookId}.json"
// Check for existing files (Hidden OR Legacy)
val existingHidden = syncDir.findFile(hiddenFileName)
val existingLegacy = syncDir.findFile(legacyFileName)
// Prefer hidden, fallback to legacy for conflict check
val existingFile = existingHidden ?: existingLegacy
if (existingFile != null && existingFile.exists()) {
try { try {
val existingContent = context.contentResolver.openInputStream(existingFile.uri)?.use { input -> val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input ->
input.bufferedReader().use { it.readText() } input.bufferedReader().use { it.readText() }
} }
if (existingContent != null) { if (existingContent != null) {
val existingMeta = FolderBookMetadata.fromJsonString(existingContent) val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
val diff = existingMeta.lastModifiedTimestamp - metadata.lastModifiedTimestamp
// Clobber Protection
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save for ${metadata.bookId}. Folder has newer data.") Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data.")
return@withContext return@withContext
} }
} }
} catch (e: Exception) { } catch (_: Exception) {}
Timber.tag(TAG).e(e, "Failed to read existing metadata for conflict check")
}
// Delete the existing file (whether hidden or legacy) before writing new one
try {
existingFile.delete()
} catch (e: Exception) {
Timber.tag(TAG).w("Failed to delete existing metadata file: ${e.message}")
}
} }
// If we had a legacy file that wasn't the 'existingFile' (edge case), delete it too var targetFile = existingHidden
if (existingLegacy != null && existingLegacy.exists()) {
try { existingLegacy.delete() } catch (_: Exception) {} 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)
} }
val newFile = syncDir.createFile("application/json", hiddenFileName) if (targetFile == null) {
if (newFile == null) {
Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}") Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}")
return@withContext return@withContext
} }
@ -83,13 +64,32 @@ object LocalSyncUtils {
val jsonString = metadata.toJsonString() val jsonString = metadata.toJsonString()
try { try {
context.contentResolver.openOutputStream(newFile.uri)?.use { output -> context.contentResolver.openFileDescriptor(targetFile.uri, "rwt")?.use { pfd ->
output.write(jsonString.toByteArray()) 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")
}
}
} }
Timber.tag(TAG).d("Saved metadata for ${metadata.bookId} (Hidden)")
val absolutePath = getPathFromUri(context, targetFile.uri)
if (absolutePath != null) {
android.media.MediaScannerConnection.scanFile(
context,
arrayOf(absolutePath),
arrayOf("application/json"),
null
)
}
Timber.tag(TAG).d("Saved hidden metadata for ${metadata.bookId}")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to write content to metadata file for ${metadata.bookId}") Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}")
try { newFile.delete() } catch (_: Exception) {}
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -97,29 +97,30 @@ object LocalSyncUtils {
} }
} }
suspend fun getBookMetadata( /**
context: Context, * Helper to attempt to resolve a SAF URI to an absolute filesystem path.
sourceFolderUri: Uri, * This is required because MediaScannerConnection does not accept content:// URIs.
bookId: String */
): FolderBookMetadata? = withContext(Dispatchers.IO) { private fun getPathFromUri(context: Context, uri: Uri): String? {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null if (DocumentsContract.isDocumentUri(context, uri) && isExternalStorageDocument(uri)) {
val syncDir = findSyncDir(rootTree) ?: return@withContext null val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":")
val type = split[0]
// Find all related files: hidden, legacy, and conflicts if ("primary".equals(type, ignoreCase = true)) {
val relatedFiles = syncDir.listFiles().filter { file -> @Suppress("DEPRECATION")
val name = file.name ?: "" return Environment.getExternalStorageDirectory().toString() + "/" + split[1]
// Match: .bookId.json, bookId.json, or containing .sync-conflict }
(name.contains(bookId)) && (name.endsWith(".json") || name.contains(".sync-conflict"))
} }
} catch (_: Exception) {
if (relatedFiles.isEmpty()) return@withContext null Timber.tag(TAG).w("Could not resolve absolute path for URI: $uri")
return@withContext resolveAndCleanConflicts(context, relatedFiles, bookId)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error resolving book metadata for $bookId")
} }
return@withContext null return null
}
private fun isExternalStorageDocument(uri: Uri): Boolean {
return "com.android.externalstorage.documents" == uri.authority
} }
/** /**
@ -142,9 +143,8 @@ object LocalSyncUtils {
} }
if (jsonString != null) { if (jsonString != null) {
val meta = FolderBookMetadata.fromJsonString(jsonString) val meta = FolderBookMetadata.fromJsonString(jsonString)
// Ensure this file actually belongs to the book (defensive check against partial name matches)
if (meta.bookId == bookId) { if (meta.bookId == bookId) {
if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) { if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta.lastModifiedTimestamp) {
bestMeta = meta bestMeta = meta
bestFile = file bestFile = file
} }
@ -157,23 +157,19 @@ object LocalSyncUtils {
// 2. Clean up losers // 2. Clean up losers
if (bestMeta != null && bestFile != null) { if (bestMeta != null && bestFile != null) {
val filesToDelete = files.filter { it.uri != bestFile!!.uri } val filesToDelete = files.filter { it.uri != bestFile.uri }
if (filesToDelete.isNotEmpty()) { if (filesToDelete.isNotEmpty()) {
Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile!!.name}. Deleting ${filesToDelete.size} obsolete files.") Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile.name}. Deleting ${filesToDelete.size} obsolete files.")
filesToDelete.forEach { filesToDelete.forEach {
try { it.delete() } catch(_: Exception) {} try { it.delete() } catch(_: Exception) {}
} }
} }
// 3. Migrate Legacy to Hidden if needed // 3. Migrate Legacy to Hidden if needed
val winnerName = bestFile!!.name ?: "" val winnerName = bestFile.name ?: ""
if (!winnerName.startsWith(".")) { if (!winnerName.startsWith(".")) {
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName") Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName")
// We can't always rename easily with DocumentFile, so we allow 'saveMetadataToFolder'
// to handle the actual file swap next time a write happens, OR we could force a rewrite.
// For now, we leave it. The clutter is reduced by deleting conflicts.
// The next save operation will create the hidden file and delete this one.
} }
} }
@ -188,30 +184,16 @@ object LocalSyncUtils {
try { try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
val syncDir = findSyncDir(rootTree) ?: return@withContext finalResults
// Ensure .nomedia exists while scanning // CHANGED: Scanning rootTree directly
ensureNoMedia(syncDir) val allFiles = rootTree.listFiles()
val allFiles = syncDir.listFiles()
// Group files by bookId.
// Filename formats:
// 1. hidden: .[bookId].json
// 2. legacy: [bookId].json
// 3. conflict: .[bookId].sync-conflict... or [bookId].sync-conflict...
val groupedFiles = allFiles val groupedFiles = allFiles
.filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true } .filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true }
.groupBy { file -> .groupBy { file ->
var name = file.name ?: "" var name = file.name ?: ""
// Remove leading dot
if (name.startsWith(".")) name = name.substring(1) if (name.startsWith(".")) name = name.substring(1)
// Remove conflict suffix
name = name.substringBefore(".sync-conflict") name = name.substringBefore(".sync-conflict")
// Remove extension
name.substringBefore(".json") name.substringBefore(".json")
} }
@ -222,39 +204,11 @@ object LocalSyncUtils {
} }
} }
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records.") Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error scanning .episteme folder") Timber.tag(TAG).e(e, "Error scanning root folder for metadata")
} }
return@withContext finalResults return@withContext finalResults
} }
private fun findSyncDir(root: DocumentFile): DocumentFile? {
// Look for exact match first
val standardDir = root.findFile(SYNC_DIR_NAME)
if (standardDir != null && standardDir.isDirectory) return standardDir
// Fallback search
val files = root.listFiles()
return files.firstOrNull {
it.isDirectory && (it.name == SYNC_DIR_NAME)
}
}
private fun getOrCreateSyncDir(root: DocumentFile): DocumentFile? {
val existing = findSyncDir(root)
if (existing != null) return existing
return root.createDirectory(SYNC_DIR_NAME)
}
private fun ensureNoMedia(dir: DocumentFile) {
if (dir.findFile(".nomedia") == null) {
try {
dir.createFile("application/octet-stream", ".nomedia")
} catch (e: Exception) {
Timber.tag(TAG).w("Failed to create .nomedia file")
}
}
}
} }

View file

@ -120,6 +120,15 @@ class RecentFilesRepository(private val context: Context) {
val folderUriString = entity.sourceFolderUri val folderUriString = entity.sourceFolderUri
if (folderUriString != null) { if (folderUriString != null) {
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
val isDirty = entity.isRecent || hasProgress || hasBookmarks
if (!isDirty) {
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
return@withContext
}
Timber.d("Syncing metadata to local folder for book: $bookId") Timber.d("Syncing metadata to local folder for book: $bookId")
val metadata = FolderBookMetadata( val metadata = FolderBookMetadata(
@ -140,7 +149,7 @@ class RecentFilesRepository(private val context: Context) {
) )
LocalSyncUtils.saveMetadataToFolder( LocalSyncUtils.saveMetadataToFolder(
context = context, // Now correctly references the property context = context,
sourceFolderUri = folderUriString.toUri(), sourceFolderUri = folderUriString.toUri(),
metadata = metadata metadata = metadata
) )