From 7d26e10c0771f104b42d6784defe3476d4b7c8bb Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 2 Mar 2026 13:15:34 +0530 Subject: [PATCH] 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. --- .../java/com/aryan/reader/FolderSyncWorker.kt | 170 +++++++-------- .../java/com/aryan/reader/MainViewModel.kt | 35 ++-- .../aryan/reader/MetadataExtractionWorker.kt | 42 ++-- .../com/aryan/reader/data/LocalSyncUtils.kt | 194 +++++++----------- .../reader/data/RecentFilesRepository.kt | 11 +- 5 files changed, 195 insertions(+), 257 deletions(-) diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index afca361..cad4d0b 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -21,7 +21,6 @@ package com.aryan.reader import android.content.Context -import android.net.Uri import timber.log.Timber import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -32,9 +31,6 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.aryan.reader.data.RecentFileItem 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.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -48,10 +44,6 @@ class FolderSyncWorker( ) : CoroutineWorker(appContext, workerParams) { 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 { const val WORK_NAME = "FolderSyncWorker" @@ -62,6 +54,13 @@ class FolderSyncWorker( override suspend fun doWork(): Result { 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...") return withContext(Dispatchers.IO) { @@ -80,6 +79,7 @@ class FolderSyncWorker( val folderUri = folderUriString.toUri() try { + // Permission checks ... try { appContext.contentResolver.takePersistableUriPermission( folderUri, @@ -94,21 +94,55 @@ 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( + 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) { + Timber.tag("FolderSync").d("Phase 2: Scanning physical files...") val currentDiskFiles = mutableListOf() val fileQueue = ArrayDeque() documentTree.listFiles().let { fileQueue.addAll(it) } while (fileQueue.isNotEmpty()) { + if (isStopped) break + val file = fileQueue.removeAt(0) if (file.isDirectory) { - if (file.name == ".episteme") continue + // REMOVED: if (file.name == ".episteme") continue file.listFiles().let { fileQueue.addAll(it) } } else if (file.isFile) { 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) } } @@ -117,12 +151,15 @@ class FolderSyncWorker( val foundBookIds = mutableSetOf() for (file in currentDiskFiles) { + if (isStopped) break + val stableId = "local_${file.name}_${file.length()}" foundBookIds.add(stableId) 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" @@ -132,6 +169,7 @@ 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, @@ -139,7 +177,8 @@ class FolderSyncWorker( author = remoteMeta?.author, isAvailable = true, 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, lastChapterIndex = remoteMeta?.lastChapterIndex, lastPage = remoteMeta?.lastPage, @@ -149,55 +188,43 @@ class FolderSyncWorker( locatorBlockIndex = remoteMeta?.locatorBlockIndex, locatorCharOffset = remoteMeta?.locatorCharOffset ) + + // Insert. Note: We do NOT call syncLocalMetadataToFolder here. + // It is "Clean" until the user opens it. recentFilesRepository.addRecentFile(newItem) } else { - var itemToUpdate = existingItem - - if (existingItem.isDeleted) { - itemToUpdate = itemToUpdate.copy(isDeleted = false, isAvailable = true) - } - - 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) + // 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 } } - val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) - val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } + // Cleanup removed files + if (!isStopped) { + val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } - if (idsToRemove.isNotEmpty()) { - Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") - recentFilesRepository.deleteFilePermanently(idsToRemove) + if (idsToRemove.isNotEmpty()) { + Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") + recentFilesRepository.deleteFilePermanently(idsToRemove) + } } } prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) } - Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") - val metaRequest = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(appContext).enqueueUniqueWork( - MetadataExtractionWorker.WORK_NAME, - ExistingWorkPolicy.APPEND_OR_REPLACE, - metaRequest - ) + if (!isStopped) { + Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") + val metaRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(appContext).enqueueUniqueWork( + MetadataExtractionWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + metaRequest + ) + } 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 { return name.endsWith(".pdf", true) || name.endsWith(".epub", true) || diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index b52940c..07e677a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -1373,9 +1373,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun disconnectSyncedFolder() { viewModelScope.launch { 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) { Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.") - recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call + recentFilesRepository.deleteFilesBySourceFolder(folderUriString) try { val uri = folderUriString.toUri() @@ -1387,14 +1399,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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 { val rootUri = item.sourceFolderUri.toUri() val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri) - val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme") - if (syncDir != null) { - // Try hidden first, then legacy - val metaFile = syncDir.findFile(".${item.bookId}.json") - ?: syncDir.findFile("${item.bookId}.json") + if (rootDoc != null) { + val hiddenMeta = rootDoc.findFile(".${item.bookId}.json") + val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json") - metaFile?.delete() + hiddenMeta?.delete() + legacyVisibleMeta?.delete() + + Timber.tag("FolderSync").d("Deleted metadata for ${item.bookId} from root.") } } catch (e: Exception) { Timber.e(e, "Error deleting metadata file for ${item.bookId}") diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index d48a19b..97a2070 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -28,8 +28,13 @@ class MetadataExtractionWorker( } 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 { - // Fetch all books that are from a folder but don't have a cover yet (implies metadata likely missing/basic) val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers() if (filesToProcess.isEmpty()) { @@ -41,6 +46,8 @@ class MetadataExtractionWorker( filesToProcess.forEach { item -> if (isStopped) return@forEach + if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach + try { val uri = item.uriString?.toUri() ?: return@forEach val type = item.type @@ -49,7 +56,6 @@ class MetadataExtractionWorker( var title: String? = null var author: String? = null - // We open the stream briefly to extract metadata appContext.contentResolver.openInputStream(uri)?.use { inputStream -> when (type) { FileType.EPUB -> { @@ -58,35 +64,25 @@ class MetadataExtractionWorker( originalBookNameHint = item.displayName, parseContent = false ) - book.let { - title = it.title.takeIf { t -> t.isNotBlank() } - author = it.author.takeIf { a -> a.isNotBlank() } - it.coverImage?.let { img -> - coverPath = recentFilesRepository.saveCoverToCache(img, uri) - } - } + title = book.title.takeIf { it.isNotBlank() } + author = book.author.takeIf { it.isNotBlank() } + book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } } FileType.MOBI -> { - val book = mobiParser.createMobiBook( - inputStream = inputStream, - originalBookNameHint = item.displayName - ) + val book = mobiParser.createMobiBook(inputStream, item.displayName) book?.let { title = it.title.takeIf { t -> t.isNotBlank() } author = it.author.takeIf { a -> a.isNotBlank() } - it.coverImage?.let { img -> - coverPath = recentFilesRepository.saveCoverToCache(img, uri) - } + it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) } } } FileType.PDF -> { - // PDF cover generation is heavy, but necessary pdfCoverGenerator.generateCover(uri)?.let { 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 ) 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) { diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index e1d212c..38faeb1 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -3,13 +3,15 @@ package com.aryan.reader.data import android.content.Context import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber object LocalSyncUtils { - private const val SYNC_DIR_NAME = "episteme" + // REMOVED: private const val SYNC_DIR_NAME = "episteme" private const val TAG = "FolderSync" suspend fun saveMetadataToFolder( @@ -20,62 +22,41 @@ object LocalSyncUtils { try { 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) { - Timber.tag(TAG).e("Could not create/find $SYNC_DIR_NAME directory in $sourceFolderUri") - return@withContext - } + val existingHidden = rootTree.findFile(syncFileName) + val existingVisible = rootTree.findFile(legacyVisibleName) - // Ensure .nomedia exists to prevent gallery clutter - ensureNoMedia(syncDir) + val fileToCheck = existingHidden ?: existingVisible - // Use hidden filename to avoid "Recents" clutter - 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()) { + if (fileToCheck != null && fileToCheck.exists()) { try { - val existingContent = context.contentResolver.openInputStream(existingFile.uri)?.use { input -> + val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input -> input.bufferedReader().use { it.readText() } } - if (existingContent != null) { val existingMeta = FolderBookMetadata.fromJsonString(existingContent) - val diff = existingMeta.lastModifiedTimestamp - metadata.lastModifiedTimestamp - - // Clobber Protection 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 } } - } catch (e: 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}") - } + } catch (_: Exception) {} } - // If we had a legacy file that wasn't the 'existingFile' (edge case), delete it too - if (existingLegacy != null && existingLegacy.exists()) { - try { existingLegacy.delete() } 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) } - val newFile = syncDir.createFile("application/json", hiddenFileName) - if (newFile == null) { + if (targetFile == null) { Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}") return@withContext } @@ -83,13 +64,32 @@ object LocalSyncUtils { val jsonString = metadata.toJsonString() try { - context.contentResolver.openOutputStream(newFile.uri)?.use { output -> - output.write(jsonString.toByteArray()) + context.contentResolver.openFileDescriptor(targetFile.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") + } + } } - 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) { - Timber.tag(TAG).e(e, "Failed to write content to metadata file for ${metadata.bookId}") - try { newFile.delete() } catch (_: Exception) {} + Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}") } } catch (e: Exception) { @@ -97,29 +97,30 @@ object LocalSyncUtils { } } - suspend fun getBookMetadata( - context: Context, - sourceFolderUri: Uri, - bookId: String - ): FolderBookMetadata? = withContext(Dispatchers.IO) { + /** + * Helper to attempt to resolve a SAF URI to an absolute filesystem path. + * This is required because MediaScannerConnection does not accept content:// URIs. + */ + private fun getPathFromUri(context: Context, uri: Uri): String? { try { - val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null - val syncDir = findSyncDir(rootTree) ?: return@withContext null + if (DocumentsContract.isDocumentUri(context, uri) && isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":") + val type = split[0] - // Find all related files: hidden, legacy, and conflicts - val relatedFiles = syncDir.listFiles().filter { file -> - val name = file.name ?: "" - // Match: .bookId.json, bookId.json, or containing .sync-conflict - (name.contains(bookId)) && (name.endsWith(".json") || name.contains(".sync-conflict")) + if ("primary".equals(type, ignoreCase = true)) { + @Suppress("DEPRECATION") + return Environment.getExternalStorageDirectory().toString() + "/" + split[1] + } } - - if (relatedFiles.isEmpty()) return@withContext null - - return@withContext resolveAndCleanConflicts(context, relatedFiles, bookId) - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Error resolving book metadata for $bookId") + } catch (_: Exception) { + Timber.tag(TAG).w("Could not resolve absolute path for URI: $uri") } - 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) { val meta = FolderBookMetadata.fromJsonString(jsonString) - // Ensure this file actually belongs to the book (defensive check against partial name matches) if (meta.bookId == bookId) { - if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) { + if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta.lastModifiedTimestamp) { bestMeta = meta bestFile = file } @@ -157,23 +157,19 @@ object LocalSyncUtils { // 2. Clean up losers if (bestMeta != null && bestFile != null) { - val filesToDelete = files.filter { it.uri != bestFile!!.uri } + val filesToDelete = files.filter { it.uri != bestFile.uri } 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 { try { it.delete() } catch(_: Exception) {} } } // 3. Migrate Legacy to Hidden if needed - val winnerName = bestFile!!.name ?: "" + val winnerName = bestFile.name ?: "" if (!winnerName.startsWith(".")) { 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 { val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults - val syncDir = findSyncDir(rootTree) ?: return@withContext finalResults - // Ensure .nomedia exists while scanning - ensureNoMedia(syncDir) + // CHANGED: Scanning rootTree directly + 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 .filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true } .groupBy { file -> var name = file.name ?: "" - - // Remove leading dot if (name.startsWith(".")) name = name.substring(1) - - // Remove conflict suffix name = name.substringBefore(".sync-conflict") - - // Remove extension 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) { - Timber.tag(TAG).e(e, "Error scanning .episteme folder") + Timber.tag(TAG).e(e, "Error scanning root folder for metadata") } 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") - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 89ca4e5..ffd0149 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -120,6 +120,15 @@ class RecentFilesRepository(private val context: Context) { val folderUriString = entity.sourceFolderUri 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") val metadata = FolderBookMetadata( @@ -140,7 +149,7 @@ class RecentFilesRepository(private val context: Context) { ) LocalSyncUtils.saveMetadataToFolder( - context = context, // Now correctly references the property + context = context, sourceFolderUri = folderUriString.toUri(), metadata = metadata )