/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ // FolderSyncWorker.kt 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 import androidx.work.OneTimeWorkRequestBuilder import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager 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 import kotlinx.coroutines.withContext import androidx.core.content.edit import com.aryan.reader.data.LocalSyncUtils class FolderSyncWorker( private val appContext: Context, workerParams: WorkerParameters ) : 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" const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime" const val KEY_METADATA_ONLY = "key_metadata_only" private val syncMutex = Mutex() } override suspend fun doWork(): Result { val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...") return withContext(Dispatchers.IO) { syncMutex.withLock { Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.") performSync(isMetadataOnly) } } } private suspend fun performSync(metadataOnly: Boolean): Result { val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null) if (folderUriString.isNullOrBlank()) return Result.success() val folderUri = folderUriString.toUri() try { try { appContext.contentResolver.takePersistableUriPermission( folderUri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION ) } catch (_: SecurityException) { return Result.failure() } val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) if (documentTree == null || !documentTree.isDirectory) { return Result.failure() } val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) if (!metadataOnly) { val currentDiskFiles = mutableListOf() val fileQueue = ArrayDeque() documentTree.listFiles().let { fileQueue.addAll(it) } while (fileQueue.isNotEmpty()) { val file = fileQueue.removeAt(0) if (file.isDirectory) { if (file.name == ".episteme") continue file.listFiles().let { fileQueue.addAll(it) } } else if (file.isFile) { val name = file.name ?: "" if (isValidExtension(name)) { currentDiskFiles.add(file) } } } val foundBookIds = mutableSetOf() for (file in currentDiskFiles) { val stableId = "local_${file.name}_${file.length()}" foundBookIds.add(stableId) val existingItem = recentFilesRepository.getFileByBookId(stableId) if (existingItem == null) { val remoteMeta = folderMetadataMap[stableId] val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB val placeholderTitle = file.name ?: "Unknown" val newItem = RecentFileItem( bookId = stableId, uriString = file.uri.toString(), type = type, displayName = file.name ?: "Unknown", timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(), coverImagePath = null, title = remoteMeta?.title ?: placeholderTitle, author = remoteMeta?.author, isAvailable = true, isDeleted = false, isRecent = false, sourceFolderUri = folderUriString, lastChapterIndex = remoteMeta?.lastChapterIndex, lastPage = remoteMeta?.lastPage, lastPositionCfi = remoteMeta?.lastPositionCfi, progressPercentage = remoteMeta?.progressPercentage, bookmarksJson = remoteMeta?.bookmarksJson, locatorBlockIndex = remoteMeta?.locatorBlockIndex, locatorCharOffset = remoteMeta?.locatorCharOffset ) 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) } } } 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) } } 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 ) return Result.success() } catch (e: Exception) { Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") return Result.failure() } } 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) || name.endsWith(".mobi", true) || name.endsWith(".azw3", true) || name.endsWith(".md", true) } private fun getFileType(name: String, mimeType: String?): FileType? { return when { mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI name.endsWith(".md", true) -> FileType.MD name.endsWith(".txt", true) -> FileType.TXT else -> null } } }