/* * 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 */ // MainViewModel.kt @file:Suppress("DEPRECATION", "ANNOTATION_WILL_BE_APPLIED_ALSO_TO_PROPERTY_OR_FIELD") package com.aryan.reader import android.app.Application import android.content.ClipData import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.database.Cursor import android.graphics.Bitmap import android.net.Uri import android.os.Build import com.aryan.reader.tts.TtsController import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.paginatedreader.LocatorConverter import kotlinx.serialization.protobuf.ProtoBuf import com.aryan.reader.paginatedreader.semanticBlockModule import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.annotation.OptIn import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.NoCredentialException import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.aryan.reader.data.BookMetadata import com.aryan.reader.data.CloudflareRepository import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.FeedbackRepository import com.aryan.reader.data.FirestoreRepository import com.aryan.reader.data.FontMetadata import com.aryan.reader.data.FontsRepository import com.aryan.reader.data.GoogleDriveRepository import com.aryan.reader.data.PurchaseEntity import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RemoteConfigRepository import com.aryan.reader.data.ShelfMetadata import com.aryan.reader.data.TagEntity import com.aryan.reader.data.getUri import com.aryan.reader.data.toBookMetadata import com.aryan.reader.data.toRecentFileItem import com.aryan.reader.epub.CalibreBundleExtractor import com.aryan.reader.epub.CalibreBundleResult import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.ImportedFileCache import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.SingleFileImporter import com.aryan.reader.epub.hasReadableExtractedContent import com.aryan.reader.ml.ISpeechBubbleDetector import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfUserHighlight import com.aryan.reader.pdf.PdfiumAnnotationExporter import com.aryan.reader.pdf.ReflowWorker import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfHighlightRepository import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.ExperimentalSerializationApi import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CancellationException import java.util.concurrent.Executors.newSingleThreadExecutor import java.util.concurrent.TimeUnit private data class SpeechBubbleCacheKey( val documentId: String, val pageIndex: Int ) private data class CachedSpeechBubble( val leftFraction: Float, val topFraction: Float, val rightFraction: Float, val bottomFraction: Float, val maskBitmap: Bitmap? ) @kotlin.OptIn(ExperimentalSerializationApi::class) @UnstableApi open class MainViewModel(application: Application) : AndroidViewModel(application) { private val appContext: Context = application.applicationContext private val authRepository = AuthRepository(appContext) private val recentFilesRepository = RecentFilesRepository(appContext) private val pdfTextRepository by lazy { PdfTextRepository(appContext) } private val bookCacheDao by lazy { BookCacheDatabase.getDatabase(application).bookCacheDao() } private val epubParser by lazy { EpubParser(appContext) } private val mobiParser by lazy { MobiParser(appContext) } private val fb2Parser by lazy { com.aryan.reader.epub.Fb2Parser(appContext) } private val odtParser by lazy { com.aryan.reader.epub.OdtParser(appContext) } private val singleFileImporter by lazy { SingleFileImporter(appContext) } private val bookImporter by lazy { BookImporter(appContext) } private val pageLayoutRepository by lazy { PageLayoutRepository(appContext) } private val pdfRichTextRepository by lazy { com.aryan.reader.pdf.PdfRichTextRepository(appContext) } private val pdfTextBoxRepository by lazy { PdfTextBoxRepository(appContext) } private val pdfHighlightRepository by lazy { PdfHighlightRepository(appContext) } private val pdfAnnotationRepository by lazy { PdfAnnotationRepository(appContext) } private val prefs: SharedPreferences = application.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) private val firestoreRepository = FirestoreRepository() private val googleDriveRepository = GoogleDriveRepository() private val billingClientWrapper = BillingClientWrapper(appContext, viewModelScope) { purchase -> verifyPurchaseWithBackend(purchase) } private val cloudflareRepository = CloudflareRepository() private val remoteConfigRepository = RemoteConfigRepository() private var userProfileListener: Any? = null private val _prefsUpdateFlow = MutableStateFlow(0L) private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener private val feedbackRepository = FeedbackRepository(appContext) private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver()) private var feedbackListener: Any? = null private val importMutex = Mutex() private val epubRecoveryMutex = Mutex() private val _navigationEvent = Channel(Channel.BUFFERED) @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() private var pendingSwitchDeferred: CompletableDeferred? = null private var externalOpenedBookId: String? = null private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null private var speechBubbleDetector: ISpeechBubbleDetector? = null private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher() private val speechBubbleCacheMutex = Mutex() private val speechBubbleCache = ConcurrentHashMap>() private val speechBubbleDetectionJobs = ConcurrentHashMap>>() private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? { if (panelDetector == null && BuildConfig.DEBUG) { val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite") if (modelFile.exists()) { try { val clazz = Class.forName("com.aryan.reader.ml.ComicPanelDetector") panelDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as com.aryan.reader.ml.IPanelDetector } catch (e: Exception) { Timber.e(e, "Failed to instantiate ComicPanelDetector via reflection") } } else { Timber.e("Model file best_float16.tflite not found in external files dir") } } return panelDetector } private fun getOrInitSpeechBubbleDetector(context: Context): ISpeechBubbleDetector? { if (speechBubbleDetector == null && BuildConfig.FLAVOR != "oss") { val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") if (modelFile.exists()) { try { val clazz = Class.forName("com.aryan.reader.ml.SpeechBubbleDetector") speechBubbleDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as ISpeechBubbleDetector } catch (t: Throwable) { Timber.e(t, "Failed to instantiate SpeechBubbleDetector via reflection. Deleting corrupted model.") modelFile.delete() } } else { Timber.e("Model file manga_speech_bubble_v3.ort not found in external files dir") } } return speechBubbleDetector } private fun normalizeSpeechBubbles( bubbles: List, width: Int, height: Int ): List { if (width <= 0 || height <= 0) return emptyList() val widthF = width.toFloat() val heightF = height.toFloat() return bubbles.mapNotNull { bubble -> val bounds = bubble.bounds if (bounds.width() <= 0f || bounds.height() <= 0f) { null } else { CachedSpeechBubble( leftFraction = (bounds.left / widthF).coerceIn(0f, 1f), topFraction = (bounds.top / heightF).coerceIn(0f, 1f), rightFraction = (bounds.right / widthF).coerceIn(0f, 1f), bottomFraction = (bounds.bottom / heightF).coerceIn(0f, 1f), maskBitmap = bubble.maskBitmap ) } } } private fun scaleCachedSpeechBubbles( bubbles: List, width: Int, height: Int ): List { if (width <= 0 || height <= 0) return emptyList() val widthF = width.toFloat() val heightF = height.toFloat() return bubbles.mapNotNull { bubble -> val bounds = android.graphics.RectF( bubble.leftFraction * widthF, bubble.topFraction * heightF, bubble.rightFraction * widthF, bubble.bottomFraction * heightF ) if (bounds.width() <= 0f || bounds.height() <= 0f) { null } else { SpeechBubble(bounds = bounds, maskBitmap = bubble.maskBitmap) } } } fun hasCachedSpeechBubbles(documentId: String, pageIndex: Int): Boolean { return speechBubbleCache.containsKey(SpeechBubbleCacheKey(documentId, pageIndex)) } fun testSpeechBubbleDetection(context: Context) { viewModelScope.launch(mlDispatcher) { try { // <--- We now wrap the WHOLE thing in a Throwable catch val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") if (!modelFile.exists()) { withContext(Dispatchers.Main) { showBanner("ONNX Model not found", isError = true) } return@launch } val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ } ?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ } if (cbzItem == null) { withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) } return@launch } val uri = cbzItem.getUri() ?: return@launch Timber.d("BUBBLE TEST START: ${cbzItem.displayName}") var cacheFile: File? = null try { Timber.d("Initializing Speech Bubble Detector...") val detector = getOrInitSpeechBubbleDetector(context) ?: run { withContext(Dispatchers.Main) { showBanner("ONNX Model could not be loaded", isError = true) } return@launch } Timber.d("Detector successfully initialized. Copying CBZ to cache...") cacheFile = File(context.cacheDir, "temp_test_bubble.cbz") context.contentResolver.openInputStream(uri)?.use { input -> cacheFile.outputStream().use { output -> input.copyTo(output) } } Timber.d("CBZ copied to cache successfully. Opening archive...") val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) val totalPages = archiveDoc.getPageCount() Timber.d("Archive opened. Total pages: $totalPages") val targetIndex = 2 if (targetIndex < totalPages) { Timber.d("Reading page $targetIndex...") val page = archiveDoc.openPage(targetIndex) if (page != null) { val w = page.getPageWidthPoint() val h = page.getPageHeightPoint() if (w > 0 && h > 0) { Timber.d("Rendering bitmap: $w x $h...") val bitmap = androidx.core.graphics.createBitmap(w, h) page.renderPageBitmap(bitmap, 0, 0, w, h, false) Timber.d("Running ONNX Inference...") val pageStartTime = System.currentTimeMillis() val bubbles = detector.detectBubbles(bitmap, confidenceThreshold = 0.4f) val pageDuration = System.currentTimeMillis() - pageStartTime val logLine = "Page $targetIndex: ${pageDuration}ms (Found ${bubbles.size} bubbles)" Timber.d(">>> [BUBBLE] $logLine") withContext(Dispatchers.Main) { showBanner("Bubble Test Complete! $logLine") } bitmap.recycle() } page.close() } } else { Timber.e("Page $targetIndex out of bounds") withContext(Dispatchers.Main) { showBanner("CBZ does not have a 3rd page.", isError = true) } } archiveDoc.close() Timber.d("Archive closed cleanly.") } finally { cacheFile?.delete() } } catch (t: Throwable) { Timber.e(t, "Fatal error during bubble test") } } } val speechBubbleModelDownloadProgress = MutableStateFlow(null) fun isSpeechBubbleModelAvailable(context: Context): Boolean { return File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort").exists() } fun downloadSpeechBubbleModel(context: Context) { if (speechBubbleModelDownloadProgress.value != null) return viewModelScope.launch(Dispatchers.IO) { speechBubbleModelDownloadProgress.value = 0f var success = false val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") val tempFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort.tmp") val urlString = "https://huggingface.co/1m4ryan/speech-bubble-detector/resolve/main/manga_speech_bubble_v3.ort" var downloadedBytes = if (tempFile.exists()) tempFile.length() else 0L val maxRetries = 3 var retryCount = 0 while (retryCount < maxRetries && !success) { try { val url = java.net.URL(urlString) val connection = url.openConnection() as java.net.HttpURLConnection if (downloadedBytes > 0) { connection.setRequestProperty("Range", "bytes=$downloadedBytes-") } connection.connectTimeout = 15000 connection.readTimeout = 15000 connection.connect() val responseCode = connection.responseCode val isPartial = responseCode == java.net.HttpURLConnection.HTTP_PARTIAL if (responseCode == java.net.HttpURLConnection.HTTP_OK && downloadedBytes > 0) { downloadedBytes = 0L } else if (responseCode != java.net.HttpURLConnection.HTTP_OK && !isPartial) { throw Exception("HTTP error code: $responseCode") } val contentLength = connection.getHeaderField("Content-Length")?.toLongOrNull() ?: -1L val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L val input = connection.inputStream val output = FileOutputStream(tempFile, isPartial) val data = ByteArray(16 * 1024) var count: Int while (input.read(data).also { count = it } != -1) { output.write(data, 0, count) downloadedBytes += count if (totalFileLength > 0) { speechBubbleModelDownloadProgress.value = (downloadedBytes.toFloat() / totalFileLength).coerceIn(0f, 1f) } } output.flush() output.close() input.close() if (tempFile.exists() && tempFile.length() > 0) { if (modelFile.exists()) modelFile.delete() if (tempFile.renameTo(modelFile)) { success = true } } } catch (e: CancellationException) { throw e } catch (e: Exception) { Timber.e(e, "Failed to download Bubble Zoom model, attempt ${retryCount + 1}") retryCount++ if (retryCount >= maxRetries) break delay(2000) downloadedBytes = if (tempFile.exists()) tempFile.length() else 0L } } speechBubbleModelDownloadProgress.value = null withContext(Dispatchers.Main) { if (success) { showBanner("Bubble Zoom model downloaded successfully!") } else { showBanner("Download failed. Please keep the app open during download.", isError = true) } } } } data class PageModificationResult( val layout: List, val annotations: Map>, val bookmarksJson: String ) val proUpgradeState = billingClientWrapper.proUpgradeState val ttsController by lazy { TtsController(appContext).apply { connect() } } private var backgroundTtsBook: EpubBook? = null private var backgroundTtsBookId: String? = null private var backgroundTtsCoverPath: String? = null private val _internalState = MutableStateFlow( ReaderScreenState( renderMode = try { val savedRenderModeName = prefs.getString( KEY_RENDER_MODE, RenderMode.VERTICAL_SCROLL.name ) RenderMode.valueOf( savedRenderModeName ?: RenderMode.VERTICAL_SCROLL.name ) } catch (_: IllegalArgumentException) { RenderMode.VERTICAL_SCROLL }, sortOrder = try { val savedSortOrderName = prefs.getString( KEY_SORT_ORDER, SortOrder.RECENT.name ) SortOrder.valueOf( savedSortOrderName ?: SortOrder.RECENT.name ) } catch (_: IllegalArgumentException) { SortOrder.RECENT }, addBooksSource = try { val savedSourceName = prefs.getString( KEY_ADD_BOOKS_SOURCE, AddBooksSource.UNSHELVED.name ) AddBooksSource.valueOf( savedSourceName ?: AddBooksSource.UNSHELVED.name ) } catch (_: IllegalArgumentException) { AddBooksSource.UNSHELVED }, mainScreenStartPage = prefs.getInt(KEY_MAIN_SCREEN_START_PAGE, 0).coerceIn(0, 1), libraryScreenStartPage = prefs.getInt( KEY_LIBRARY_SCREEN_START_PAGE, 0 ).coerceIn(0, if (BuildConfig.IS_OFFLINE) 2 else 3), viewingShelfId = prefs.getString(KEY_LAST_VIEWING_SHELF_ID, null), isAddingBooksToShelf = prefs.getBoolean(KEY_LAST_ADDING_BOOKS_TO_SHELF, false), currentUser = authRepository.getSignedInUser(), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), libraryFilters = LibraryFilters( fileTypes = prefs.getStringSet(KEY_FILTER_FILE_TYPES, emptySet())?.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }?.toSet() ?: emptySet(), sourceFolders = prefs.getStringSet(KEY_FILTER_FOLDERS, emptySet()) ?: emptySet(), readStatus = runCatching { ReadStatusFilter.valueOf(prefs.getString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) ?: ReadStatusFilter.ALL.name) }.getOrDefault(ReadStatusFilter.ALL), tagIds = prefs.getStringSet(KEY_FILTER_TAG_IDS, emptySet()) ?: emptySet() ), syncedFolders = loadSyncedFoldersFromPrefs(), lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( KEY_LAST_FOLDER_SCAN_TIME, 0L ) else null, pinnedHomeBookIds = prefs.getStringSet(KEY_PINNED_HOME, emptySet()) ?: emptySet(), pinnedLibraryBookIds = prefs.getStringSet(KEY_PINNED_LIBRARY, emptySet()) ?: emptySet(), recentFilesLimit = prefs.getInt(KEY_RECENT_FILES_LIMIT, 0), isTabsEnabled = prefs.getBoolean(KEY_TABS_ENABLED, false), openTabIds = prefs.getString(KEY_OPEN_TAB_IDS, null)?.let { try { val arr = JSONArray(it) List(arr.length()) { i -> arr.getString(i) } } catch(_: Exception) { emptyList() } } ?: emptyList(), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false), isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false), appThemeMode = try { AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name) } catch (_: Exception) { AppThemeMode.SYSTEM }, appContrastOption = try { AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name) } catch (_: Exception) { AppContrastOption.STANDARD }, appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)), appTextDimFactorDark = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)), appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null, customAppThemes = loadCustomAppThemes(prefs) ) ) private suspend fun prepareBookForImport(externalUri: Uri): ImportResult? { val displayName = getFileNameFromUri(externalUri, appContext) var type = getFileTypeFromUri(externalUri, appContext) val hash = FileHasher.calculateSha256 { appContext.contentResolver.openInputStream(externalUri) } if (hash == null) { Timber.e("Failed to process file hash for $externalUri") return null } val existingItem = recentFilesRepository.getFileByBookId(hash) if (existingItem != null) { Timber.i("Book with ID: $hash already exists. Skipping import.") return null } val fileName = displayName ?: "" if (fileName.endsWith(".zip", ignoreCase = true) || type == FileType.CBZ) { val bundleResult = CalibreBundleExtractor.processZip(appContext, externalUri, hash, bookImporter, recentFilesRepository) Timber.d("MainViewModel: Calibre processZip returned: $bundleResult") if (bundleResult != null) { return ImportResult( internalUri = bundleResult.internalBookUri, bookId = hash, type = bundleResult.type, bundleResult = bundleResult ) } if (type == null) type = FileType.CBZ } if (type == null) return null Timber.i("Importing new book with ID: $hash") val internalFile = bookImporter.importBook(externalUri) ?: return null return ImportResult(internalFile.toUri(), hash, type, null) } val libraryFlow = combine( recentFilesRepository.getRecentFilesFlow(), recentFilesRepository.activeShelvesFlow, recentFilesRepository.shelfCrossRefsFlow, ::Triple ) val tagFlow = combine( recentFilesRepository.tagsFlow, recentFilesRepository.tagCrossRefsFlow, ::Pair ) open val uiState: StateFlow = combine( _internalState, libraryFlow, tagFlow ) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) -> withContext(Dispatchers.Default) { libraryStateProjector.project( LibraryProjectionInput( state = internalState, recentFilesFromDb = recentFilesFromDb, dbShelves = dbShelves, shelfRefs = shelfRefs, dbTags = dbTags, tagRefs = tagRefs ) ) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = _internalState.value ) fun setTabsEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } _internalState.update { it.copy(isTabsEnabled = enabled) } if (!enabled) { val active = _internalState.value.activeTabBookId val newTabs = if (active != null) listOf(active) else emptyList() prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(newTabs).toString()) } _internalState.update { it.copy(openTabIds = newTabs) } } } fun switchTab(bookId: String) { Timber.tag("PdfTabSync").i("ViewModel: switchTab called for bookId: $bookId") val item = uiState.value.rawLibraryFiles.find { it.bookId == bookId } ?: run { Timber.tag("PdfTabSync").e("ViewModel: switchTab FAILED - BookId $bookId not found in library") return } val currentTabs = _internalState.value.openTabIds.toMutableList() if (!currentTabs.contains(bookId)) { if (currentTabs.size >= 20) { viewModelScope.launch(Dispatchers.Main) { showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) } return } currentTabs.add(bookId) } prefs.edit { putString(KEY_ACTIVE_TAB, bookId) putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) } val uri = item.getUri() Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}") uri?.let { persistReaderSession(bookId, item.type) Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it") _internalState.update { state -> state.copy( openTabIds = currentTabs, activeTabBookId = bookId, selectedPdfUri = it, selectedBookId = bookId, selectedFileType = item.type, initialPageInBook = item.lastPage, initialBookmarksJson = item.bookmarksJson, isLoading = false, errorMessage = null ) } viewModelScope.launch { addFileToRecent( it, item.type, bookId, customDisplayName = item.displayName, isRecent = true, sourceFolderUri = item.sourceFolderUri ) } } ?: run { _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } } } fun openTagSelection(bookIds: Set) { if (bookIds.isEmpty()) return _internalState.update { it.copy(showTagSelectionDialogFor = bookIds) } } fun closeTagSelection() { _internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) } } fun createAndAssignTag(name: String, bookIds: Set) { val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) if (sanitizedBookIds.isEmpty()) return viewModelScope.launch { val tagId = UUID.randomUUID().toString() val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE) val color = colors.random().toInt() val now = System.currentTimeMillis() val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch recentFilesRepository.createTag(tag) sanitizedBookIds.forEach { bookId -> recentFilesRepository.assignTagToBook(bookId, tagId) } } } fun toggleTagForBooks(tagId: String, bookIds: Set, assign: Boolean) { val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds) if (tagId.isBlank() || sanitizedBookIds.isEmpty()) return viewModelScope.launch { sanitizedBookIds.forEach { bookId -> if (assign) { recentFilesRepository.assignTagToBook(bookId, tagId) } else { recentFilesRepository.removeTagFromBook(bookId, tagId) } } } } private fun buildDefaultTags(): List { val now = System.currentTimeMillis() return listOf( TagEntity(id = "default_to_read", name = "To Read", color = 0xFF64B5F6.toInt(), createdAt = now), TagEntity(id = "default_reading", name = "Reading", color = 0xFF81C784.toInt(), createdAt = now + 1), TagEntity(id = "default_finished", name = "Finished", color = 0xFFFFB74D.toInt(), createdAt = now + 2), TagEntity(id = "default_favorites", name = "Favorites", color = 0xFFF06292.toInt(), createdAt = now + 3), TagEntity(id = "default_reference", name = "Reference", color = 0xFF9575CD.toInt(), createdAt = now + 4) ) } private var googleFontsCache: List = emptyList() fun loadGoogleFontsList(context: Context): List { if (googleFontsCache.isEmpty()) { try { val jsonString = context.assets.open("google_fonts.json").bufferedReader().use { it.readText() } val jsonArray = org.json.JSONArray(jsonString) val list = mutableListOf() for (i in 0 until jsonArray.length()) { list.add(jsonArray.getString(i)) } googleFontsCache = list Timber.d("Loaded ${list.size} Google Fonts from assets.") } catch (e: Exception) { Timber.e(e, "Failed to load google_fonts.json from assets") } } return googleFontsCache } fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { try { val encodedName = java.net.URLEncoder.encode(fontName, "UTF-8") val url = java.net.URL("https://fonts.googleapis.com/css?family=$encodedName") val connection = url.openConnection() as java.net.HttpURLConnection // CRITICAL: We spoof an old Safari User-Agent. This forces Google to return the raw .ttf file instead of .woff2 connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1") if (connection.responseCode != 200) { withContext(Dispatchers.Main) { showBanner("Font '$fontName' not found on server.", isError = true) } return@launch } val css = connection.inputStream.bufferedReader().readText() val regex = """url\((https://[^)]+)\)""".toRegex() val match = regex.find(css) if (match != null) { val fontUrl = match.groupValues[1] val ext = fontUrl.substringAfterLast(".", "ttf").lowercase() // Strict format validation if (ext != "ttf" && ext != "otf") { withContext(Dispatchers.Main) { showBanner("Unsupported format ($ext) returned for $fontName", isError = true) } return@launch } val fontConnection = java.net.URL(fontUrl).openConnection() as java.net.HttpURLConnection val tempFile = File(appContext.cacheDir, "$fontName.$ext") fontConnection.inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } val result = fontsRepository.importFont(Uri.fromFile(tempFile)) result.onSuccess { font -> if (uiState.value.isSyncEnabled) { uploadNewFont(font) } withContext(Dispatchers.Main) { showBanner("$fontName downloaded successfully!") } }.onFailure { withContext(Dispatchers.Main) { showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true) } } tempFile.delete() } else { withContext(Dispatchers.Main) { showBanner("Could not parse download link for $fontName", isError = true) } } } catch (e: Exception) { Timber.e(e, "Failed to download Google Font: $fontName") withContext(Dispatchers.Main) { showBanner("Failed to download $fontName: ${e.localizedMessage}", isError = true) } } finally { withContext(Dispatchers.Main) { onComplete() } } } } fun closeTab(bookId: String) { Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId") val currentTabs = _internalState.value.openTabIds.toMutableList() currentTabs.remove(bookId) if (currentTabs.isEmpty()) { prefs.edit { remove(KEY_OPEN_TAB_IDS) remove(KEY_ACTIVE_TAB) } _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } clearSelectedFile() } else { val activeTab = _internalState.value.activeTabBookId if (activeTab == bookId) { val nextTabId = currentTabs.last() prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) putString(KEY_ACTIVE_TAB, nextTabId) } _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = nextTabId) } switchTab(nextTabId) } else { prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) } _internalState.update { it.copy(openTabIds = currentTabs) } } } } fun onSearchQueryChange(newQuery: String) { _internalState.update { if (it.isSearchActive) { it.copy(searchQuery = newQuery) } else { it } } } fun setSearchActive(active: Boolean) { _internalState.update { if (active) { it.copy(isSearchActive = true) } else { it.copy(isSearchActive = false, searchQuery = "") } } } fun streamOpdsBook( bookId: String, title: String, urlTemplate: String, pageCount: Int, catalogId: String? ) { val encodedUrl = Uri.encode(urlTemplate) val safeId = Uri.encode(bookId) val catId = catalogId?.let { "&catalogId=${Uri.encode(it)}" } ?: "" val uriString = "opds-pse://stream?id=$safeId&count=$pageCount&url=$encodedUrl$catId" openBook(uriString.toUri(), bookId, FileType.CBZ, title) } fun deleteStreamedBooksForCatalog(catalogId: String) { viewModelScope.launch(Dispatchers.IO) { val filesToDelete = recentFilesRepository.getAllFilesForSync().filter { it.uriString?.contains("catalogId=$catalogId") == true } if (filesToDelete.isNotEmpty()) { val ids = filesToDelete.map { it.bookId } ids.forEach { bookId -> cleanupBookDataLocally(bookId) try { val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}") if (cacheDir.exists()) cacheDir.deleteRecursively() } catch (e: Exception) { Timber.e(e, "Failed to clean stream cache for $bookId") } } recentFilesRepository.deleteFilePermanently(ids) withContext(Dispatchers.Main) { showBanner(appContext.getString(R.string.banner_removed_streaming_books, filesToDelete.size)) } } } } private val _reviewRequestEvent = Channel(Channel.BUFFERED) val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow() private var hasRequestedReviewInThisSession = false suspend fun loadPageLayout(bookId: String, totalPdfPages: Int): List { return pageLayoutRepository.loadLayout(bookId, totalPdfPages) } suspend fun addPage( bookId: String, currentLayout: List, insertIndex: Int, currentAnnotations: Map>, currentBookmarksJson: String, referenceWidth: Int, referenceHeight: Int, wasManuallyAdded: Boolean = false ): PageModificationResult = withContext(Dispatchers.Default) { Timber.d("Adding page at index $insertIndex for book $bookId (manual=$wasManuallyAdded)") val newLayout = currentLayout.toMutableList() val safeIndex = insertIndex.coerceIn(0, newLayout.size) val newPage = VirtualPage.BlankPage( id = UUID.randomUUID().toString(), width = referenceWidth, height = referenceHeight, wasManuallyAdded = wasManuallyAdded ) newLayout.add(safeIndex, newPage) val newAnnotations = mutableMapOf>() currentAnnotations.forEach { (pageIdx, annots) -> val newIdx = if (pageIdx >= safeIndex) pageIdx + 1 else pageIdx val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } newAnnotations[newIdx] = shiftedAnnots } val newTotalPages = newLayout.size val newBookmarksJson = try { if (currentBookmarksJson.isNotBlank()) { val jsonArray = JSONArray(currentBookmarksJson) val newArray = JSONArray() for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) val bmPageIndex = obj.getInt("pageIndex") val title = obj.getString("title") val newBmPageIndex = if (bmPageIndex >= safeIndex) bmPageIndex + 1 else bmPageIndex val newObj = JSONObject() newObj.put("pageIndex", newBmPageIndex) newObj.put("title", title) newObj.put("totalPages", newTotalPages) newArray.put(newObj) } newArray.toString() } else { "[]" } } catch (e: Exception) { Timber.e(e, "Error shifting bookmarks") currentBookmarksJson } pageLayoutRepository.saveLayout(bookId, newLayout) PageModificationResult(newLayout, newAnnotations, newBookmarksJson) } suspend fun removePage( bookId: String, currentLayout: List, removeIndex: Int, currentAnnotations: Map>, currentBookmarksJson: String ): PageModificationResult = withContext(Dispatchers.Default) { Timber.d("Removing page at index $removeIndex for book $bookId") val newLayout = currentLayout.toMutableList() if (removeIndex in newLayout.indices) { newLayout.removeAt(removeIndex) } else { return@withContext PageModificationResult( currentLayout, currentAnnotations, currentBookmarksJson ) } val newAnnotations = mutableMapOf>() currentAnnotations.forEach { (pageIdx, annots) -> if (pageIdx != removeIndex) { val newIdx = if (pageIdx > removeIndex) pageIdx - 1 else pageIdx val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } newAnnotations[newIdx] = shiftedAnnots } } val newTotalPages = newLayout.size val newBookmarksJson = try { if (currentBookmarksJson.isNotBlank()) { val jsonArray = JSONArray(currentBookmarksJson) val newArray = JSONArray() for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) val bmPageIndex = obj.getInt("pageIndex") if (bmPageIndex == removeIndex) continue val title = obj.getString("title") val newBmPageIndex = if (bmPageIndex > removeIndex) bmPageIndex - 1 else bmPageIndex val newObj = JSONObject() newObj.put("pageIndex", newBmPageIndex) newObj.put("title", title) newObj.put("totalPages", newTotalPages) newArray.put(newObj) } newArray.toString() } else { "[]" } } catch (e: Exception) { Timber.e(e, "Error shifting bookmarks") currentBookmarksJson } pageLayoutRepository.saveLayout(bookId, newLayout) PageModificationResult(newLayout, newAnnotations, newBookmarksJson) } init { Timber.d("ViewModel instance created.") WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME) val locatorConverter = LocatorConverter( bookCacheDao, ProtoBuf { serializersModule = semanticBlockModule }, appContext ) viewModelScope.launch { var wasSessionFinished = false ttsController.ttsState.collect { state -> val isPlaying = state.isPlaying val sessionFinished = state.sessionFinished val isReaderSource = state.playbackSource == "READER" if (isReaderSource) { if (sessionFinished && !wasSessionFinished) { if (_internalState.value.selectedEpubBook == null) { Timber.tag("TTS_BG_ADVANCE").i("Reader is closed. Handling auto-advance in background.") advanceTtsChapterInBackground(state, locatorConverter) } } if (state.sessionEndedByStop) { backgroundTtsBook = null backgroundTtsBookId = null backgroundTtsCoverPath = null } } wasSessionFinished = sessionFinished } } viewModelScope.launch { recentFilesRepository.migrateLegacyShelvesToRoom() if (!prefs.getBoolean(KEY_DEFAULT_TAGS_SEEDED, false)) { recentFilesRepository.seedTagsIfEmpty(buildDefaultTags()) prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) } } } val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0) prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) } prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_SHELVES || key?.startsWith(KEY_SHELF_CONTENT_PREFIX) == true) { Timber.d("Shelf preference changed ($key), triggering UI refresh.") _prefsUpdateFlow.value = System.currentTimeMillis() } } prefs.registerOnSharedPreferenceChangeListener(prefsListener) remoteConfigRepository.init() if (_internalState.value.syncedFolders.isNotEmpty()) { triggerFolderSyncWorker(metadataOnly = false, showFeedback = false) } sweepOrphanedCache() restoreReaderSessionIfNeeded() viewModelScope.launch { billingClientWrapper.initializeConnection() } viewModelScope.launch { authRepository.observeAuthState().collect { newUserData -> firestoreRepository.removeListener(userProfileListener) firestoreRepository.removeListener(feedbackListener) _internalState.update { it.copy(currentUser = newUserData) } billingClientWrapper.refreshPurchasesAsync() if (newUserData != null) { registerOrUpdateDeviceOnSignIn(newUserData.uid) feedbackListener = feedbackRepository.listenForUnreadFeedback(newUserData.uid) { hasUnread -> _internalState.update { it.copy(hasUnreadFeedback = hasUnread) } } userProfileListener = firestoreRepository.listenToUserProfile(newUserData.uid) { isProFromBackend, creditsFromBackend -> _internalState.update { it.copy(isProUser = isProFromBackend, credits = creditsFromBackend) } if (isProFromBackend) { verifyDeviceForProUser() if (_internalState.value.isSyncEnabled) { viewModelScope.launch { Timber.tag("AnnotationSync").d( "Startup: Pro user & Sync enabled. Initiating cloud sync." ) if (googleDriveRepository.hasDrivePermissions(appContext)) { syncWithCloud(showBanner = false) } else { Timber.tag("AnnotationSync").d( "Startup: Sync skipped. Missing Drive permissions." ) } } } } } } else { _internalState.update { it.copy(isProUser = false, credits = 0, isSyncEnabled = false, hasUnreadFeedback = false) } } } } viewModelScope.launch { combine( billingClientWrapper.proUpgradeState.map { it.activePurchases }, _internalState.map { it.currentUser?.uid } ) { purchases, uid -> Pair(purchases, uid) } .distinctUntilChanged() .collect { (purchases, uid) -> if (uid != null && purchases.isNotEmpty()) { Timber.d("Active purchases or User changed, triggering migration check") triggerLegacyPurchaseMigration() } } } } private fun persistReaderSession(bookId: String, type: FileType) { prefs.edit { putString(KEY_LAST_OPEN_BOOK_ID, bookId) putString(KEY_LAST_OPEN_FILE_TYPE, type.name) } } private fun clearPersistedReaderSession() { prefs.edit { remove(KEY_LAST_OPEN_BOOK_ID) remove(KEY_LAST_OPEN_FILE_TYPE) } } private fun restoreReaderSessionIfNeeded() { val currentState = _internalState.value if (currentState.selectedBookId != null || currentState.selectedPdfUri != null || currentState.selectedEpubUri != null) { return } val persistedType = prefs.getString(KEY_LAST_OPEN_FILE_TYPE, null)?.let { typeName -> runCatching { FileType.valueOf(typeName) }.getOrNull() } val restoreBookId = prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) ?: return if (persistedType == null) { clearPersistedReaderSession() return } viewModelScope.launch { val item = recentFilesRepository.getFileByBookId(restoreBookId) val restoreUri = item?.getUri() if (item == null || restoreUri == null || item.type != persistedType) { Timber.tag("ReaderRestore") .w("Skipping restore for bookId=$restoreBookId. Item missing, URI missing, or type mismatch.") clearPersistedReaderSession() return@launch } when { item.type in PDF_VIEWER_FILE_TYPES -> { _internalState.update { state -> if (state.selectedBookId != null || state.selectedPdfUri != null || state.selectedEpubUri != null) { state } else { state.copy( selectedPdfUri = restoreUri, selectedBookId = item.bookId, selectedEpubBook = null, selectedEpubUri = null, selectedFileType = item.type, isLoading = false, errorMessage = null, initialLocator = null, initialCfi = null, initialBookmarksJson = item.bookmarksJson, initialHighlightsJson = null, initialPageInBook = item.lastPage ) } } persistReaderSession(item.bookId, item.type) Timber.tag("ReaderRestore").i("Restored reader session for ${item.bookId} (${item.type}).") } item.type in EPUB_READER_FILE_TYPES -> { val locator = if (item.lastChapterIndex != null && item.locatorBlockIndex != null && item.locatorCharOffset != null) { Locator( chapterIndex = item.lastChapterIndex, blockIndex = item.locatorBlockIndex, charOffset = item.locatorCharOffset ) } else { null } _internalState.update { state -> if (state.selectedBookId != null || state.selectedPdfUri != null || state.selectedEpubUri != null) { state } else { state.copy( selectedPdfUri = null, selectedBookId = item.bookId, selectedEpubBook = null, selectedEpubUri = restoreUri, selectedFileType = item.type, isLoading = true, errorMessage = null, initialLocator = locator, initialCfi = item.lastPositionCfi, initialBookmarksJson = item.bookmarksJson, initialHighlightsJson = item.highlightsJson, initialPageInBook = null ) } } runCatching { restoreEpubReaderBook(item, restoreUri) }.onSuccess { restoredBook -> _internalState.update { state -> if (state.selectedBookId != item.bookId) { state } else { state.copy(selectedEpubBook = restoredBook, isLoading = false, errorMessage = null) } } persistReaderSession(item.bookId, item.type) Timber.tag("ReaderRestore").i("Restored reader session for ${item.bookId} (${item.type}).") }.onFailure { error -> Timber.tag("ReaderRestore").e(error, "Failed to restore EPUB-like session for ${item.bookId}") clearPersistedReaderSession() _internalState.update { state -> if (state.selectedBookId != item.bookId) { state } else { state.copy( selectedBookId = null, selectedEpubUri = null, selectedEpubBook = null, selectedFileType = null, isLoading = false, errorMessage = appContext.getString(R.string.error_load_file, error.message) ) } } } } else -> { clearPersistedReaderSession() } } } } private suspend fun restoreEpubReaderBook(item: RecentFileItem, uri: Uri): EpubBook { return restoreEpubReaderBook(item.type, item.bookId, item.displayName, uri) } private suspend fun restoreEpubReaderBook( type: FileType, bookId: String, displayName: String, uri: Uri ): EpubBook = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) { throw Exception("Could not open input stream for restore") } when (type) { FileType.EPUB -> epubParser.createEpubBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName ) FileType.MOBI -> mobiParser.createMobiBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName ) ?: throw Exception( if (MobiParser.isNativeParserAvailable) { "MobiParser returned null. The file might be DRM-protected or invalid." } else { MobiParser.nativeParserUnavailableMessage() } ) FileType.FB2 -> fb2Parser.createFb2Book( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName ) FileType.ODT, FileType.FODT -> odtParser.createOdtBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName, isFlat = type == FileType.FODT ) FileType.MD, FileType.TXT, FileType.HTML, FileType.DOCX -> singleFileImporter.importSingleFile( inputStream = inputStream, type = type, originalBookNameHint = displayName, bookId = bookId ) else -> throw IllegalArgumentException("Unsupported reader restore type: $type") } } } fun recoverSelectedEpubContent() { val state = _internalState.value val bookId = state.selectedBookId ?: return val uri = state.selectedEpubUri ?: return val type = state.selectedFileType ?: return if (type !in EPUB_READER_FILE_TYPES) return val displayName = state.selectedEpubBook?.fileName ?: state.selectedEpubBook?.title ?: getFileNameFromUri(uri, appContext) ?: "unknown_book" viewModelScope.launch { epubRecoveryMutex.withLock { val latestState = _internalState.value if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) { return@withLock } if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) { return@withLock } _internalState.update { if (it.selectedBookId == bookId) it.copy(isLoading = true, errorMessage = null) else it } runCatching { val item = recentFilesRepository.getFileByBookId(bookId) restoreEpubReaderBook( type = item?.type ?: type, bookId = bookId, displayName = item?.displayName ?: displayName, uri = uri ) }.onSuccess { restoredBook -> _internalState.update { if (it.selectedBookId == bookId && it.selectedEpubUri == uri) { it.copy(selectedEpubBook = restoredBook, isLoading = false, errorMessage = null) } else { it } } Timber.tag("EpubRecovery").i("Recovered missing extracted content for $bookId") }.onFailure { error -> Timber.tag("EpubRecovery").e(error, "Failed to recover missing extracted content for $bookId") _internalState.update { if (it.selectedBookId == bookId && it.selectedEpubUri == uri) { it.copy( isLoading = false, errorMessage = appContext.getString(R.string.error_load_file, error.message) ) } else { it } } } } } } private fun getDisplayPathFromUri(context: Context, uriString: String): String { val uri = uriString.toUri() val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder" if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri) .isNotEmpty() ) { val documentId = DocumentsContract.getTreeDocumentId(uri) val split = documentId.split(":") if (split.size > 1) { return split[1] } } return fallbackName } private val fontsRepository = FontsRepository(appContext) val customFonts = fontsRepository.getAllFonts().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList() ) private suspend fun syncFonts(userId: String) { Timber.d("Starting Font Sync...") val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return // 1. Fetch Metadata val localFonts = fontsRepository.getAllFontsForSync() val remoteFonts = firestoreRepository.getAllFonts(userId) val localFontsMap = localFonts.associateBy { it.id } val remoteFontsMap = remoteFonts.associateBy { it.id } val allFontIds = (localFontsMap.keys + remoteFontsMap.keys).distinct() val driveFiles = googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } allFontIds.forEach { fontId -> val local = localFontsMap[fontId] val remote = remoteFontsMap[fontId] if (local != null && remote == null) { if (!local.isDeleted) { val meta = FontMetadata( local.id, local.displayName, local.fileName, local.fileExtension, local.timestamp, false ) firestoreRepository.syncFontMetadata(userId, meta) } } else if (local == null && remote != null) { fontsRepository.addFontFromSync(remote) } else if (local != null && remote != null) { if (local.isDeleted && !remote.isDeleted) { firestoreRepository.syncFontMetadata(userId, remote.copy(isDeleted = true)) } else if (!local.isDeleted && remote.isDeleted) { fontsRepository.deleteFont(local.id) } } } val finalLocalFonts = fontsRepository.getAllFontsForSync() finalLocalFonts.forEach { font -> if (font.isDeleted) { driveFiles[font.fileName]?.id?.let { fileId -> googleDriveRepository.deleteDriveFile(accessToken, fileId) } firestoreRepository.deleteFontMetadata(userId, font.id) fontsRepository.deletePermanently(font.id) } else { val driveFile = driveFiles[font.fileName] val localFile = fontsRepository.getFontFile(font.fileName) if (localFile.exists() && driveFile == null) { Timber.d("Uploading font file: ${font.fileName}") googleDriveRepository.uploadFont( accessToken, font.fileName, localFile, font.fileExtension ) } else if (!localFile.exists() && driveFile != null) { Timber.d("Downloading font file: ${font.fileName}") googleDriveRepository.downloadFile(accessToken, driveFile.id, localFile) } } } Timber.d("Font Sync Complete.") } fun importFont(uri: Uri) { viewModelScope.launch { _internalState.update { it.copy(isLoading = true) } val result = fontsRepository.importFont(uri) result.onSuccess { font -> if (uiState.value.isSyncEnabled) { uploadNewFont(font) } }.onFailure { showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true) } _internalState.update { it.copy(isLoading = false) } } } private fun uploadNewFont(font: CustomFontEntity) = viewModelScope.launch { try { val currentUser = uiState.value.currentUser ?: return@launch val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch val meta = FontMetadata( font.id, font.displayName, font.fileName, font.fileExtension, font.timestamp, false ) firestoreRepository.syncFontMetadata(currentUser.uid, meta) val file = File(font.path) if (file.exists()) { googleDriveRepository.uploadFont( accessToken, font.fileName, file, font.fileExtension ) } } catch (e: Exception) { Timber.e(e, "Failed to upload new font immediately") } } fun deleteFont(fontId: String) { viewModelScope.launch { fontsRepository.deleteFont(fontId) } } fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) { viewModelScope.launch { @Suppress("UnusedVariable", "Unused") val item = recentFilesRepository.getFileByBookId(bookId) ?: return@launch Timber.d("Deleting book permanently from reader: $bookId") cleanupBookDataLocally(bookId) recentFilesRepository.deleteFilePermanently(listOf(bookId)) withContext(Dispatchers.Main) { onDeleted() } } } private fun getInstallationId(): String { var installationId = prefs.getString(KEY_INSTALLATION_ID, null) if (installationId == null) { installationId = UUID.randomUUID().toString() prefs.edit { putString(KEY_INSTALLATION_ID, installationId) } Timber.d("Generated new stable installation ID: $installationId") } return installationId } private fun getDeviceName(): String { val manufacturer = Build.MANUFACTURER val model = Build.MODEL return if (model.startsWith(manufacturer, ignoreCase = true)) { model } else { "$manufacturer $model" }.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } fun onDrivePermissionFlowCancelled() { _internalState.update { it.copy(isRequestingDrivePermission = false, isSyncEnabled = false) } prefs.edit { putBoolean(KEY_SYNC_ENABLED, false) } showBanner(appContext.getString(R.string.error_sync_drive_permission), isError = true) } private fun verifyPurchaseWithBackend( purchase: PurchaseEntity, isSilentMigrationCheck: Boolean = false ) { viewModelScope.launch { val productId = purchase.products.firstOrNull() if (productId == null || (!productId.startsWith("credits_") && productId != BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID)) { Timber.e("Purchase verification failed: Incorrect product ID.") if (!isSilentMigrationCheck) { _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true)) } } billingClientWrapper.clearVerificationState() return@launch } val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken, productId) if (result.isSuccess) { Timber.i("Backend verification successful. Firestore will update the app.") if (productId.startsWith("credits_")) { billingClientWrapper.consumePurchase(purchase.purchaseToken) if (!isSilentMigrationCheck) { _internalState.update { it.copy(bannerMessage = BannerMessage("Credits successfully added!")) } } } else { if (!isSilentMigrationCheck) { _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_upgrade_success))) } } verifyDeviceForProUser() } } else { val exception = result.exceptionOrNull() if (exception?.message?.contains("already claimed") == true) { Timber.i("Migration/Refresh check: Purchase token is already claimed. Silently ignoring.") if (productId.startsWith("credits_")) { billingClientWrapper.consumePurchase(purchase.purchaseToken) } } else { val errorMessage = appContext.getString(R.string.error_purchase_verification) Timber.e(exception, "Backend verification failed") if (!isSilentMigrationCheck) { _internalState.update { it.copy(bannerMessage = BannerMessage(errorMessage, isError = true)) } } } } if (!isSilentMigrationCheck) { billingClientWrapper.clearVerificationState() } } } fun verifyDeviceForProUser() { if (!_internalState.value.isProUser) return val currentUser = _internalState.value.currentUser ?: return viewModelScope.launch { val deviceId = getInstallationId() when (val deviceStatus = firestoreRepository.getDeviceStatus(currentUser.uid, deviceId)) { is com.aryan.reader.data.DeviceStatus.Active -> { Timber.d("Device is active. Updating last seen.") firestoreRepository.updateDeviceLastSeen(currentUser.uid, deviceId) } is com.aryan.reader.data.DeviceStatus.Revoked -> { Timber.w("Device has been revoked. Signing out.") firestoreRepository.deleteDevice(currentUser.uid, deviceId) // Clean up signOut() showBanner(appContext.getString(R.string.banner_device_removed)) } is com.aryan.reader.data.DeviceStatus.NotFound -> { Timber.d("Device not found during verification. Triggering full registration.") registerOrUpdateDeviceOnSignIn(currentUser.uid) } is com.aryan.reader.data.DeviceStatus.Error -> { Timber.e(deviceStatus.exception, "Error checking device status.") _internalState.update { it.copy( errorMessage = appContext.getString(R.string.error_verify_device) ) } } } } } fun replaceDevice(deviceToRemoveId: String) { val currentUser = _internalState.value.currentUser ?: return _internalState.update { it.copy(isReplacingDevice = true) } viewModelScope.launch { val newDeviceId = getInstallationId() val newDeviceName = getDeviceName() val success = firestoreRepository.replaceDevice( userId = currentUser.uid, deviceToRemoveId = deviceToRemoveId, newDeviceId = newDeviceId, newDeviceName = newDeviceName, originDeviceId = newDeviceId ) if (success) { Timber.d("Device replaced successfully.") _internalState.update { it.copy( deviceLimitState = DeviceLimitReachedState(isLimitReached = false), isReplacingDevice = false ) } } else { Timber.e("Failed to replace device.") _internalState.update { it.copy( errorMessage = appContext.getString(R.string.error_update_devices), isReplacingDevice = false ) } } } } private fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() try { if (uri.scheme == "file") { uri.path?.let { val file = File(it) result = "${file.name}_${file.length()}" } } else { context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" result = "${name}_${size}" } } } } catch (e: Exception) { Timber.e(e, "Failed to generate fast file ID") } return result } fun savePdfWithAnnotations( sourceUri: Uri, destUri: Uri, annotations: Map>, richTextPageLayouts: List? = null, textBoxes: List? = null, highlights: List? = null, bookId: String ) { viewModelScope.launch { _internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_pdf))) } try { val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId) val outputStream = appContext.contentResolver.openOutputStream(destUri) if (outputStream != null) { PdfiumAnnotationExporter.exportAnnotatedPdf( context = appContext, sourceUri = sourceUri, destStream = outputStream, virtualPages = virtualPages, inkAnnotations = annotations, richTextPageLayouts = richTextPageLayouts, textBoxes = textBoxes, highlights = highlights ) showBanner(appContext.getString(R.string.banner_pdf_saved)) } else { showBanner(appContext.getString(R.string.error_open_file_saving), isError = true) } } catch (e: Exception) { Timber.e(e, "Failed to save annotated PDF") showBanner(appContext.getString(R.string.error_saving_pdf, e.localizedMessage), isError = true) } finally { _internalState.update { it.copy(isLoading = false) } } } } fun saveOriginalPdf(sourceUri: Uri, destUri: Uri) { viewModelScope.launch { _internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_saving_original_pdf))) } try { val contentResolver = appContext.contentResolver contentResolver.openInputStream(sourceUri)?.use { input -> contentResolver.openOutputStream(destUri)?.use { output -> input.copyTo(output) } } showBanner(appContext.getString(R.string.banner_original_pdf_saved)) } catch (e: Exception) { Timber.e(e, "Failed to save original PDF") showBanner(appContext.getString(R.string.error_saving_pdf, e.localizedMessage), isError = true) } finally { _internalState.update { it.copy(isLoading = false) } } } } fun togglePinForContextualItems(isHome: Boolean) { val selectedIds = _internalState.value.contextualActionItems.map { it.bookId }.toSet() if (selectedIds.isEmpty()) return _internalState.update { state -> val currentPins = if (isHome) state.pinnedHomeBookIds else state.pinnedLibraryBookIds val allPinned = selectedIds.all { it in currentPins } val newPins = if (allPinned) currentPins - selectedIds else currentPins + selectedIds prefs.edit { putStringSet(if (isHome) KEY_PINNED_HOME else KEY_PINNED_LIBRARY, newPins) } if (isHome) { state.copy(pinnedHomeBookIds = newPins, contextualActionItems = emptySet()) } else { state.copy(pinnedLibraryBookIds = newPins, contextualActionItems = emptySet()) } } } fun updateLibraryFilters(filters: LibraryFilters) { _internalState.update { it.copy(libraryFilters = filters) } prefs.edit { putStringSet(KEY_FILTER_FILE_TYPES, filters.fileTypes.map { it.name }.toSet()) putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) putString(KEY_FILTER_READ_STATUS, filters.readStatus.name) putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) } Timber.d("Library filters updated and persisted: $filters") } suspend fun sharePdf( activityContext: Context, sourceUri: Uri, annotations: Map>, richTextPageLayouts: List? = null, textBoxes: List? = null, highlights: List? = null, includeAnnotations: Boolean, filename: String, bookId: String? = null ) { withContext(Dispatchers.IO) { val resolvedBookId = bookId ?: recentFilesRepository.getFileByUri(sourceUri.toString())?.bookId ?: getFastFileId(appContext, sourceUri) try { val shareDir = File(appContext.cacheDir, "shared_files") if (shareDir.exists()) { shareDir.listFiles()?.forEach { file -> try { file.delete() } catch (_: Exception) { Timber.w("Failed to delete temp share file: ${file.name}") } } } else { shareDir.mkdirs() } val destFile = File(shareDir, filename) FileOutputStream(destFile).use { outputStream -> if (includeAnnotations) { val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId) PdfiumAnnotationExporter.exportAnnotatedPdf( context = appContext, sourceUri = sourceUri, destStream = outputStream, virtualPages = virtualPages, inkAnnotations = annotations, richTextPageLayouts = richTextPageLayouts, textBoxes = textBoxes, highlights = highlights ) } else { appContext.contentResolver.openInputStream(sourceUri)?.use { input -> input.copyTo(outputStream) } } } val authority = "${appContext.packageName}.provider" val contentUri = androidx.core.content.FileProvider.getUriForFile( appContext, authority, destFile ) val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "application/pdf" putExtra(Intent.EXTRA_STREAM, contentUri) putExtra(Intent.EXTRA_TITLE, filename) putExtra(Intent.EXTRA_SUBJECT, appContext.getString(R.string.share_subject, filename)) clipData = ClipData.newRawUri(filename, contentUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val chooser = Intent.createChooser(shareIntent, appContext.getString(R.string.share_chooser_title)) if (activityContext !is android.app.Activity) { chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } withContext(Dispatchers.Main) { activityContext.startActivity(chooser) } } catch (e: Exception) { Timber.e(e, "Share failed") showBanner(appContext.getString(R.string.error_share_failed, e.localizedMessage), isError = true) } } } private fun uploadSingleBookMetadata(book: RecentFileItem) { if (!uiState.value.isSyncEnabled) return if (book.uriString?.startsWith("opds-pse") == true) { Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}") return } if (book.sourceFolderUri != null) { Timber.d("Skipping metadata sync for local folder book: ${book.displayName}") return } if (book.isManualOnlyReaderFile()) { Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}") return } val currentUser = uiState.value.currentUser ?: return viewModelScope.launch { try { val deviceId = getInstallationId() Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}") val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId) val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId) val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId) val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId) val hasInk = inkFile?.exists() == true val hasRichText = richTextFile.exists() val hasLayout = layoutFile.exists() val hasTextBoxes = textBoxFile.exists() val hasHighlights = highlightFile.exists() val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " + "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData" ) if (hasAnyData) { if (googleDriveRepository.hasDrivePermissions(appContext)) { val accessToken = googleDriveRepository.getAccessToken(appContext) if (accessToken != null) { val bundleJson = JSONObject() bundleJson.put("version", 2) fun putJsonSafe(key: String, file: File?) { if (file == null || !file.exists()) return try { val content = file.readText().trim() if (key == "text") { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " + "file=${file.absolutePath}" ) } if (content.startsWith("[")) { bundleJson.put(key, JSONArray(content)) } else if (content.startsWith("{")) { bundleJson.put(key, JSONObject(content)) } } catch (e: Exception) { if (key == "text") { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) .e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}") } Timber.e(e, "Failed to parse local $key file") } } if (hasInk) putJsonSafe("ink", inkFile) if (hasRichText) putJsonSafe("text", richTextFile) if (hasLayout) putJsonSafe("layout", layoutFile) if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile) if (hasHighlights) putJsonSafe("highlights", highlightFile) val bundleFile = File(appContext.cacheDir, "sync_bundle_${book.bookId}.json") val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString()) bundleFile.writeText(canonicalBundle) if (hasRichText) { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " + "bundleFile=${bundleFile.absolutePath}" ) } val uploaded = googleDriveRepository.uploadAnnotationFile( accessToken, book.bookId, bundleFile ) bundleFile.delete() if (uploaded != null) { if (hasRichText) { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) .d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}") } Timber.tag("AnnotationSync") .d("Bundle upload SUCCESS. ID: ${uploaded.id}") } else { if (hasRichText) { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG) .e("android.cloud.export.uploadFailed book=${book.bookId}") } Timber.tag("AnnotationSync") .e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.") return@launch } } } } else { Timber.tag("AnnotationSync") .d("No local data (ink/text/layout) to upload for ${book.bookId}") } val newTimestamp = System.currentTimeMillis() val metadataToSync = book.toBookMetadata().copy( lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData ) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp)) Timber.tag("AnnotationSync") .d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)") } catch (e: Exception) { Timber.tag("AnnotationSync").e(e, "Failed to sync book data: ${book.bookId}") } } } fun hideItemsFromRecentsView() { val itemsToHide = _internalState.value.contextualActionItems if (itemsToHide.isNotEmpty()) { Timber.d("DeleteDebug: Hiding ${itemsToHide.size} items from recents view.") viewModelScope.launch { val bookIdsToHide = itemsToHide.map { it.bookId } Timber.d("DeleteDebug: Marking book IDs as not recent: $bookIdsToHide") recentFilesRepository.markAsNotRecent(bookIdsToHide) _internalState.update { it.copy(contextualActionItems = emptySet()) } if (uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions( appContext ) ) { bookIdsToHide.forEach { bookId -> val updatedItem = recentFilesRepository.getFileByBookId(bookId) if (updatedItem != null) { Timber.d( "DeleteDebug: Found updated item ${updatedItem.bookId} to sync, isRecent=${updatedItem.isRecent}" ) uploadSingleBookMetadata(updatedItem) } else { Timber.w( "DeleteDebug: Could not find item with bookId $bookId after marking as not recent." ) } } } } } else { Timber.w("DeleteDebug: Attempted to hide items, but none were selected.") } } fun getDriveSignInIntent(context: Context): Intent { return googleDriveRepository.getSignInIntent(context) } fun onDrivePermissionResult(data: Intent?) { viewModelScope.launch { _internalState.update { it.copy(isRequestingDrivePermission = false) } val success = googleDriveRepository.handleSignInResult(data) if (success) { Timber.d("Drive permission granted.") setSyncEnabled(true) } else { Timber.w("Drive permission denied or failed.") onDrivePermissionFlowCancelled() } } } open fun clearSelectedFile() { Timber.i("clearSelectedFile called.") val appOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0) if (!hasRequestedReviewInThisSession && appOpenCount >= 3) { viewModelScope.launch { _reviewRequestEvent.send(Unit) hasRequestedReviewInThisSession = true } } val closingBookId = _internalState.value.selectedBookId val uriString = _internalState.value.selectedPdfUri?.toString() ?: _internalState.value.selectedEpubUri?.toString() val ttsState = ttsController.ttsState.value val isTtsActive = ttsState.playbackSource == "READER" && (ttsState.isPlaying || ttsState.isLoading || ttsState.sessionFinished || ttsState.currentText != null) if (isTtsActive && _internalState.value.selectedEpubBook != null) { backgroundTtsBook = _internalState.value.selectedEpubBook backgroundTtsBookId = _internalState.value.selectedBookId backgroundTtsCoverPath = uiState.value.recentFiles.find { it.bookId == backgroundTtsBookId }?.coverImagePath } else if (!isTtsActive) { backgroundTtsBook = null backgroundTtsBookId = null backgroundTtsCoverPath = null } _internalState.update { it.copy( selectedPdfUri = null, selectedEpubUri = null, selectedBookId = null, selectedEpubBook = null, selectedFileType = null, isLoading = false, errorMessage = null, initialLocator = null, initialPageInBook = null ) } clearPersistedReaderSession() if (closingBookId != null && closingBookId == externalOpenedBookId) { val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" if (behavior == "ASK") { _internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) } } else if (behavior == "DELETE") { deleteBookPermanently(closingBookId) } externalOpenedBookId = null } if (uriString != null) { viewModelScope.launch { val freshBook = recentFilesRepository.getFileByUri(uriString) freshBook?.let { if (uiState.value.uploadingBookIds.contains(it.bookId)) { return@launch } if (uiState.value.isSyncEnabled) { Timber.d("Book closed, triggering metadata sync for ${it.bookId}") uploadSingleBookMetadata(it) } if (it.sourceFolderUri != null) { Timber.tag("FolderAnnotationSync") .d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}") recentFilesRepository.syncLocalMetadataToFolder(it.bookId) recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) } } } } } private fun registerOrUpdateDeviceOnSignIn(userId: String) { viewModelScope.launch { Timber.d("Starting device registration/update process for user: $userId") val installationId = getInstallationId() val deviceName = getDeviceName() firestoreRepository.getFcmToken { token -> if (token != null) { viewModelScope.launch { firestoreRepository.registerOrUpdateDevice( userId = userId, deviceId = installationId, deviceName = deviceName, fcmToken = token ) } } } } } private fun advanceTtsChapterInBackground(state: TtsPlaybackManager.TtsState, locatorConverter: LocatorConverter) { val currentChapterIndex = state.chapterIndex ?: return val book = backgroundTtsBook ?: return val bookId = backgroundTtsBookId ?: return viewModelScope.launch(Dispatchers.IO) { var nextIdx = currentChapterIndex + 1 val totalChapters = book.chapters.size var foundContent = false while (nextIdx < totalChapters) { Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.") val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx, bookId) if (!nativeChunks.isNullOrEmpty()) { val token = getAuthToken() val mode = try { TtsPlaybackManager.TtsMode.valueOf(state.ttsMode) } catch(e: Exception) { TtsPlaybackManager.TtsMode.CLOUD } withContext(Dispatchers.Main) { ttsController.start( chunks = nativeChunks, bookTitle = book.title, chapterTitle = book.chapters.getOrNull(nextIdx)?.title, coverImageUri = backgroundTtsCoverPath?.let { Uri.fromFile(File(it)).toString() }, chapterIndex = nextIdx, ttsMode = mode, playbackSource = "READER", authToken = token ) } foundContent = true // Save reading position locally val cfi = nativeChunks.firstOrNull()?.sourceCfi if (cfi != null) { val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId) if (locator != null) { recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString -> recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f) } } } break } else { Timber.tag("TTS_BG_ADVANCE").d("Chapter $nextIdx is empty natively. Skipping to next.") nextIdx++ } } if (!foundContent) { Timber.tag("TTS_BG_ADVANCE").d("Reached end of book or no content found.") withContext(Dispatchers.Main) { ttsController.stop() } } } } private fun loadSyncedFoldersFromPrefs(): List { val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null) val folders = mutableListOf() if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) { val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null) val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L) if (oldUri != null) { val name = getDisplayPathFromUri(appContext, oldUri) val migrated = SyncedFolder(oldUri, name, oldTime, FileType.entries.toSet()) folders.add(migrated) saveSyncedFoldersToPrefs(folders) prefs.edit { remove(KEY_SYNCED_FOLDER_URI) remove(KEY_LAST_FOLDER_SCAN_TIME) } } } else if (jsonString != null) { try { val jsonArray = JSONArray(jsonString) for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) val allowedFileTypes = mutableSetOf() if (obj.has("allowedFileTypes")) { val typesArray = obj.getJSONArray("allowedFileTypes") for (j in 0 until typesArray.length()) { try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {} } } else { allowedFileTypes.addAll(FileType.entries) } folders.add( SyncedFolder( uriString = obj.getString("uri"), name = obj.getString("name"), lastScanTime = obj.optLong("lastScanTime", 0L), allowedFileTypes = allowedFileTypes ) ) } } catch (e: Exception) { Timber.e(e, "Failed to parse synced folders JSON") } } return folders } private fun saveSyncedFoldersToPrefs(folders: List) { val jsonArray = JSONArray() folders.forEach { folder -> val obj = JSONObject() obj.put("uri", folder.uriString) obj.put("name", folder.name) obj.put("lastScanTime", folder.lastScanTime) val typesArray = JSONArray() folder.allowedFileTypes.forEach { typesArray.put(it.name) } obj.put("allowedFileTypes", typesArray) jsonArray.put(obj) } prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) } } fun addSyncedFolder(folderUri: Uri) { val currentFolders = _internalState.value.syncedFolders if (currentFolders.size >= MAX_FOLDER_LIMIT) { showBanner(appContext.getString(R.string.error_folder_limit_reached, MAX_FOLDER_LIMIT), isError = true) return } if (currentFolders.any { it.uriString == folderUri.toString() }) { showBanner(appContext.getString(R.string.error_folder_already_synced), isError = true) return } viewModelScope.launch { try { appContext.contentResolver.takePersistableUriPermission( folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) val name = getDisplayPathFromUri(appContext, folderUri.toString()) val newFolder = SyncedFolder(folderUri.toString(), name, 0L, FileType.entries.toSet()) val newStats = currentFolders + newFolder saveSyncedFoldersToPrefs(newStats) _internalState.update { it.copy( syncedFolders = newStats ) } triggerFolderSyncWorker( metadataOnly = false, showFeedback = true, targetFolderUriString = newFolder.uriString ) showBanner(appContext.getString(R.string.banner_folder_added, name)) } catch (e: SecurityException) { Timber.e(e, "Failed to take permissions for $folderUri") showBanner(appContext.getString(R.string.error_access_folder_permissions), isError = true) } } } fun removeSyncedFolder(folder: SyncedFolder) { viewModelScope.launch { val workManager = WorkManager.getInstance(appContext) ReaderPerfLog.d("FolderRemove request folder=${folder.uriString}") workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) val currentFolders = _internalState.value.syncedFolders.toMutableList() currentFolders.removeAll { it.uriString == folder.uriString } saveSyncedFoldersToPrefs(currentFolders) _internalState.update { it.copy(syncedFolders = currentFolders) } val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } try { appContext.contentResolver.releasePersistableUriPermission( folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) } catch (e: Exception) { Timber.w("Failed to release permissions: ${e.message}") } if (currentFolders.isEmpty()) { workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME) } showBanner(appContext.getString(R.string.banner_folder_removed)) } } fun syncFolderMetadata(showFeedback: Boolean = false) { triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback) } fun scanSyncedFolder() { triggerFolderSyncWorker(metadataOnly = false, showFeedback = true) } private fun triggerFolderSyncWorker( metadataOnly: Boolean, showFeedback: Boolean, targetFolderUriString: String? = null ) { val folders = _internalState.value.syncedFolders if (folders.isEmpty()) return val targetFolderName = targetFolderUriString ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target } ReaderPerfLog.d( "FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " + "metadataOnly=$metadataOnly feedback=$showFeedback" ) val workManager = WorkManager.getInstance(appContext) if (!metadataOnly) { workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) } val data = androidx.work.Data.Builder() .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly) .apply { if (!targetFolderUriString.isNullOrBlank()) { putString(FolderSyncWorker.KEY_TARGET_FOLDER_URI, targetFolderUriString) } } .build() val request = OneTimeWorkRequestBuilder().setInputData(data).build() workManager.enqueueUniqueWork( FolderSyncWorker.WORK_NAME_ONETIME, ExistingWorkPolicy.REPLACE, request ) viewModelScope.launch { workManager.getWorkInfoByIdFlow(request.id).collect { workInfo -> if (workInfo != null) { when (workInfo.state) { WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { if (showFeedback) { val msg = if (metadataOnly) appContext.getString(R.string.banner_folder_sync_updating) else appContext.getString(R.string.banner_folder_sync_scanning) _internalState.update { it.copy( isLoading = false, isRefreshing = true, bannerMessage = BannerMessage(msg, isPersistent = true) ) } } } WorkInfo.State.SUCCEEDED -> { _internalState.update { it.copy( isLoading = false, isRefreshing = false, bannerMessage = if (showFeedback) BannerMessage(appContext.getString(R.string.banner_folder_sync_complete)) else it.bannerMessage, lastFolderScanTime = System.currentTimeMillis(), syncedFolders = loadSyncedFoldersFromPrefs() ) } } WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { _internalState.update { it.copy( isLoading = false, isRefreshing = false, errorMessage = if (showFeedback) appContext.getString(R.string.error_sync_failed) else it.errorMessage, bannerMessage = null ) } } else -> Unit } } } } } fun updateFolderFilters(folder: SyncedFolder, newFilters: Set) { viewModelScope.launch(Dispatchers.IO) { val currentFolders = _internalState.value.syncedFolders.toMutableList() val index = currentFolders.indexOfFirst { it.uriString == folder.uriString } if (index != -1) { val updatedFolder = folder.copy(allowedFileTypes = newFilters) currentFolders[index] = updatedFolder saveSyncedFoldersToPrefs(currentFolders) _internalState.update { it.copy(syncedFolders = currentFolders) } val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) .filter { it.type !in newFilters } if (filesToRemove.isNotEmpty()) { Timber.d("Removing ${filesToRemove.size} files that no longer match the filter for folder ${folder.name}") val idsToRemove = filesToRemove.map { it.bookId } idsToRemove.forEach { bookId -> cleanupBookDataLocally(bookId) } recentFilesRepository.deleteFilePermanently(idsToRemove) } withContext(Dispatchers.Main) { triggerFolderSyncWorker( metadataOnly = false, showFeedback = true, targetFolderUriString = folder.uriString ) } } } } fun disconnectAllSyncedFolders() { viewModelScope.launch { val workManager = WorkManager.getInstance(appContext) ReaderPerfLog.d("FolderRemove disconnect all folders=${_internalState.value.syncedFolders.size}") workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME) workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME) workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME) val folders = _internalState.value.syncedFolders folders.forEach { folder -> val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString) recentFilesRepository.deleteFilesBySourceFolder(folder.uriString) filesToRemove.forEach { cleanupBookDataLocally(it.bookId) } try { appContext.contentResolver.releasePersistableUriPermission( folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION ) } catch (_: Exception) { } } prefs.edit { remove(KEY_SYNCED_FOLDERS_JSON) remove(KEY_SYNCED_FOLDER_URI) } _internalState.update { it.copy(syncedFolders = emptyList()) } } } private fun downloadBook(item: RecentFileItem, openWhenComplete: Boolean = false): Job { if (!uiState.value.isSyncEnabled) { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_enable_sync_download)) } return viewModelScope.launch {} } if (uiState.value.downloadingBookIds.contains(item.bookId)) { Timber.d("Download for ${item.bookId} is already in progress. Ignoring request.") return viewModelScope.launch {} } return viewModelScope.launch { _internalState.update { state -> state.copy(downloadingBookIds = state.downloadingBookIds + item.bookId) } try { val accessToken = googleDriveRepository.getAccessToken(appContext) ?: throw Exception("Not signed in or missing permissions") val remoteFiles = googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } val fileExtension = item.type.name.lowercase() val fileName = "${item.bookId}.$fileExtension" val driveFileId = remoteFiles[fileName]?.id if (driveFileId != null) { val destinationFile = bookImporter.createBookFile(fileName) Timber.d("Downloading book: ${item.displayName}") if (googleDriveRepository.downloadFile( accessToken, driveFileId, destinationFile ) ) { addFileToRecent( destinationFile.toUri(), item.type, item.bookId, customDisplayName = item.displayName, isRecent = true, sourceFolderUri = item.sourceFolderUri ) if (openWhenComplete) { openBook( destinationFile.toUri(), item.bookId, item.type, item.displayName ) } } else { throw Exception("Google Drive download failed.") } } else { throw Exception("File not found in Google Drive.") } } catch (e: Exception) { Timber.e(e, "Failed to download book ${item.bookId}") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_download_failed, item.displayName)) } } finally { _internalState.update { state -> state.copy(downloadingBookIds = state.downloadingBookIds - item.bookId) } } } } fun deleteAllCloudAndLocalData() { if (!uiState.value.isSyncEnabled) { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_enable_sync_clear_cloud)) } return } if (!googleDriveRepository.isUserSignedInToDrive(appContext)) { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_clear_cloud)) } return } _internalState.update { it.copy( isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_clearing_cloud_local_data)) ) } viewModelScope.launch { try { // Clear local data recentFilesRepository.clearAllLocalData() clearBookCache() pdfTextRepository.clearAllText() pdfTextBoxRepository.clearAll() // Clear cloud data val accessToken = googleDriveRepository.getAccessToken(appContext) val success = if (accessToken != null) { googleDriveRepository.deleteAllFiles(accessToken) } else { false } val currentUser = uiState.value.currentUser if (currentUser != null) { firestoreRepository.deleteAllUserFirestoreData(currentUser.uid) } if (success) { _internalState.update { it.copy( isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_local_data_cleared)) ) } } else { throw Exception("Failed to clear cloud data.") } } catch (e: Exception) { Timber.e(e, "Failed to delete all cloud and local user data.") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_clear_all_data)) } } } } private fun triggerLegacyPurchaseMigration() { val user = _internalState.value.currentUser val localPurchases = billingClientWrapper.proUpgradeState.value.activePurchases if (user != null && localPurchases.isNotEmpty()) { Timber.i("Checking for unconsumed purchases or legacy pro statuses...") localPurchases.forEach { purchase -> verifyPurchaseWithBackend(purchase, isSilentMigrationCheck = true) } } } fun deleteAllUserData() { val currentUser = _internalState.value.currentUser ?: return Timber.w("DESTRUCTIVE: Starting deletion of all user data for ${currentUser.uid}") _internalState.update { it.copy(isLoading = true) } viewModelScope.launch { try { recentFilesRepository.clearAllLocalData() clearBookCache() pdfTextRepository.clearAllText() pdfTextBoxRepository.clearAll() prefs.edit { remove(KEY_LAST_SYNC_TIMESTAMP) } _internalState.update { it.copy( isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_local_data_cleared)) ) } } catch (e: Exception) { Timber.e(e, "Failed to delete all user data.") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_clear_all_data)) } } } } fun signIn(activityContext: Context) { viewModelScope.launch { _internalState.update { it.copy(isLoading = true) } try { val user = authRepository.signIn(activityContext) if (user == null) { _internalState.update { it.copy( bannerMessage = BannerMessage(appContext.getString(R.string.error_sign_in_failed), isError = true), isLoading = false ) } } else { _internalState.update { it.copy(isLoading = false) } } } catch (_: GetCredentialCancellationException) { Timber.d("Sign-in flow was cancelled by the user.") _internalState.update { it.copy(isLoading = false) } } catch (_: CancellationException) { Timber.d("Sign-in flow was cancelled by coroutine.") _internalState.update { it.copy(isLoading = false) } } catch (e: Exception) { Timber.e(e, "An unexpected error occurred during sign-in.") val errorMessage = if (e is NoCredentialException) { appContext.getString(R.string.error_no_google_account) } else { appContext.getString(R.string.error_sign_in_internet) } _internalState.update { it.copy( bannerMessage = BannerMessage(errorMessage, isError = true), isLoading = false ) } } } } fun signOut() { viewModelScope.launch { val currentUser = _internalState.value.currentUser if (currentUser != null) { val deviceId = getInstallationId() try { withTimeoutOrNull(3000) { firestoreRepository.deleteDevice(currentUser.uid, deviceId) } Timber.i("Device $deviceId unregistered on sign out.") } catch (e: Exception) { Timber.e(e, "Failed to unregister device on sign out.") } } prefs.edit { remove(KEY_SYNC_ENABLED) } authRepository.signOut() } } fun showDeviceManagementForDebug() { if (!BuildConfig.DEBUG) return viewModelScope.launch { _internalState.value.currentUser?.let { user -> _internalState.update { it.copy(isLoading = true) } val registeredDevices = firestoreRepository.getRegisteredDevices(user.uid) val deviceItems = registeredDevices.map { DeviceItem(it.deviceId, it.deviceName, it.lastSeen) } _internalState.update { it.copy( isLoading = false, deviceLimitState = DeviceLimitReachedState( isLimitReached = true, registeredDevices = deviceItems.sortedByDescending { item -> item.lastSeen }) ) } } ?: run { showBanner(appContext.getString(R.string.error_sign_in_device_management), isError = true) } } } fun launchPurchaseFlow(activity: android.app.Activity, productId: String = BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID) { Timber.d("Attempting to launch purchase flow for $productId. Pro state is: ${proUpgradeState.value}") billingClientWrapper.launchPurchaseFlow(activity, productId) } fun clearBillingError() { billingClientWrapper.clearError() } fun setSyncEnabled(enabled: Boolean) { if (!uiState.value.isProUser) { Timber.d("Sync toggle blocked for free user.") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_sync_pro_feature)) } return } prefs.edit { putBoolean(KEY_SYNC_ENABLED, enabled) } _internalState.update { it.copy(isSyncEnabled = enabled) } if (enabled) { viewModelScope.launch { if (googleDriveRepository.hasDrivePermissions(appContext)) { syncWithCloud(showBanner = true) } else { Timber.d("Requesting Drive permission from user.") _internalState.update { it.copy(isRequestingDrivePermission = true) } } } } } fun setFolderSyncEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) } _internalState.update { it.copy(isFolderSyncEnabled = enabled) } if (enabled && uiState.value.isSyncEnabled) { viewModelScope.launch { syncWithCloud(showBanner = false) } } } private fun syncWithCloud(showBanner: Boolean = false) = viewModelScope.launch { val hasPermissions = googleDriveRepository.hasDrivePermissions(appContext) val currentUser = _internalState.value.currentUser if (!hasPermissions || currentUser == null) { if (showBanner) _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_sync)) } return@launch } if (showBanner) { _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_checking))) } } try { val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch val deviceId = getInstallationId() val remoteBooksDeferred = async(Dispatchers.IO) { firestoreRepository.getAllBooks(currentUser.uid) } val remoteShelvesDeferred = async(Dispatchers.IO) { firestoreRepository.getAllShelves(currentUser.uid) } val localBooks = withContext(Dispatchers.IO) { val allFiles = recentFilesRepository.getAllFilesForSync() val filtered = if (_internalState.value.isFolderSyncEnabled) { allFiles } else { allFiles.filter { it.sourceFolderUri == null } } filtered .filterNot { it.uriString?.startsWith("opds-pse") == true } .filterNot { it.isManualOnlyReaderFile() } } val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty() val remoteBooks = remoteBooksDeferred.await() .filterNot { it.isManualOnlyReaderFile() } val remoteShelves = remoteShelvesDeferred.await() val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet() val allKnownShelfNames = (localShelfNames + remoteShelves.map { it.name }).toSet() val localShelves = allKnownShelfNames.mapNotNull { name -> val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L) if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null val bookIds = prefs.getStringSet( "$KEY_SHELF_CONTENT_PREFIX$name", emptySet() ).orEmpty().filter { it in syncableBookIds } val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false) ShelfMetadata(name, bookIds, timestamp, isDeleted) } // 3. Merge Books val localBooksMap = localBooks.associateBy { it.bookId } val remoteBooksMap = remoteBooks.associateBy { it.bookId } val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct() allBookIds.forEach { bookId -> val local = localBooksMap[bookId] val remote = remoteBooksMap[bookId] if (local != null && remote != null) { Timber.tag("AnnotationSync").d( "Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}" ) } when { local != null && remote == null -> { uploadSingleBookMetadata(local) } local == null && remote != null -> { recentFilesRepository.addRecentFile(remote.toRecentFileItem()) if (remote.hasAnnotations) { downloadAnnotationsForBook(accessToken, bookId) } } local != null && remote != null -> { if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) { uploadSingleBookMetadata(local) } else { val isMetadataNewer = remote.lastModifiedTimestamp > local.lastModifiedTimestamp if (isMetadataNewer) { recentFilesRepository.addRecentFile( remote.toRecentFileItem() ) } val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) val highlightFile = pdfHighlightRepository.getFileForSync(bookId) val anyLocalFileExists = (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists() val localFileMissing = !anyLocalFileExists val fileLastModified = maxOf( inkFile?.lastModified() ?: 0L, richTextFile.lastModified(), layoutFile.lastModified(), textBoxFile.lastModified(), highlightFile.lastModified() ) val isFileStale = remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified) if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) { Timber.tag("AnnotationSync").d("Triggering download for $bookId.") downloadAnnotationsForBook(accessToken, bookId) } } } } } val localShelvesMap = localShelves.associateBy { it.name } val remoteShelvesMap = remoteShelves.associateBy { it.name } val allShelfNames = (localShelvesMap.keys + remoteShelvesMap.keys).distinct() allShelfNames.forEach { shelfName -> val local = localShelvesMap[shelfName] val remote = remoteShelvesMap[shelfName] when { local != null && remote == null -> firestoreRepository.syncShelf( currentUser.uid, local, deviceId ) local == null && remote != null -> { prefs.edit { val currentShelves = prefs.getStringSet(KEY_SHELVES, emptySet())?.toMutableSet() ?: mutableSetOf() if (remote.isDeleted) { currentShelves.remove(remote.name) remove("$KEY_SHELF_CONTENT_PREFIX${remote.name}") } else { currentShelves.add(remote.name) putStringSet( "$KEY_SHELF_CONTENT_PREFIX${remote.name}", remote.bookIds.filter { it in syncableBookIds }.toSet() ) } putStringSet(KEY_SHELVES, currentShelves) putLong( "$KEY_SHELF_TIMESTAMP_PREFIX${remote.name}", remote.lastModifiedTimestamp ) putBoolean( "$KEY_SHELF_DELETED_PREFIX${remote.name}", remote.isDeleted ) } } local != null && remote != null -> { if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) { firestoreRepository.syncShelf(currentUser.uid, local, deviceId) } else if (remote.lastModifiedTimestamp > local.lastModifiedTimestamp) { prefs.edit { val currentShelves = prefs.getStringSet(KEY_SHELVES, emptySet())?.toMutableSet() ?: mutableSetOf() if (remote.isDeleted) { currentShelves.remove(remote.name) remove("$KEY_SHELF_CONTENT_PREFIX${remote.name}") } else { currentShelves.add(remote.name) putStringSet( "$KEY_SHELF_CONTENT_PREFIX${remote.name}", remote.bookIds.filter { it in syncableBookIds }.toSet() ) } putStringSet(KEY_SHELVES, currentShelves) putLong( "$KEY_SHELF_TIMESTAMP_PREFIX${remote.name}", remote.lastModifiedTimestamp ) putBoolean( "$KEY_SHELF_DELETED_PREFIX${remote.name}", remote.isDeleted ) } } } } } val finalMergedBooks = withContext(Dispatchers.IO) { recentFilesRepository.getAllFilesForSync() }.filterNot { it.isManualOnlyReaderFile() } val remoteFiles = withContext(Dispatchers.IO) { googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name } } val downloadJobs = mutableListOf() finalMergedBooks.forEach { book -> val fileExtension = book.type.name.lowercase() val fileName = "${book.bookId}.$fileExtension" if (book.isDeleted) { remoteFiles[fileName]?.id?.let { fileId -> Timber.d("Deleting from Drive: $fileName") googleDriveRepository.deleteDriveFile(accessToken, fileId) } recentFilesRepository.deleteFilePermanently(listOf(book.bookId)) } else if (book.isAvailable && !remoteFiles.containsKey(fileName)) { book.getUri()?.path?.let { path -> val file = File(path) if (file.exists()) { Timber.d("Uploading book: ${book.displayName}") googleDriveRepository.uploadFile( accessToken, book.bookId, file, book.type ) } } } else if (!book.isAvailable && remoteFiles.containsKey(fileName)) { Timber.d("Sync: Triggering auto-download for ${book.displayName}") downloadJobs.add(downloadBook(book)) } } downloadJobs.joinAll() syncFonts(currentUser.uid) if (showBanner) { _internalState.update { it.copy( isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_complete)) ) } } } catch (e: Exception) { Timber.tag("AnnotationSync").e(e, "Error during cloud sync") if (showBanner) { _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_sync_library_failed)) } } } } private suspend fun downloadAnnotationsForBook(accessToken: String, bookId: String) { // We download to a temp location first to inspect the content val tempDownloadFile = File(appContext.cacheDir, "temp_download_${bookId}.json") Timber.tag("AnnotationSync").d("Attempting download of bundle for $bookId.") val didDownload = googleDriveRepository.downloadAnnotationFile(accessToken, bookId, tempDownloadFile) if (didDownload && tempDownloadFile.exists()) { Timber.tag("AnnotationSync") .d("Download SUCCESS. Size: ${tempDownloadFile.length()}. Unpacking...") try { val jsonString = tempDownloadFile.readText() Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}" ) // Determine format val isBundle = try { val obj = JSONObject(jsonString) obj.has("version") || obj.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) || obj.has("ink") || obj.has("text") || obj.has("layout") || obj.has("textBoxes") || obj.has("highlights") } catch (_: Exception) { false } val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File( appContext.filesDir, "annotations/annotation_$bookId.json" ) val richTextFile = pdfRichTextRepository.getFileForSync(bookId) val layoutFile = pageLayoutRepository.getLayoutFile(bookId) val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId) val highlightFile = pdfHighlightRepository.getFileForSync(bookId) inkFile.parentFile?.mkdirs() richTextFile.parentFile?.mkdirs() layoutFile.parentFile?.mkdirs() textBoxFile.parentFile?.mkdirs() highlightFile.parentFile?.mkdirs() if (isBundle) { val bundle = JSONObject( SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString) ) Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}" ) fun writeSafe(key: String, file: File) { if (bundle.has(key)) { file.parentFile?.mkdirs() val content = bundle.get(key).toString() file.writeText(content) if (key == "text") { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}" ) } } else { if (key == "text" && file.exists()) { Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d( "android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}" ) } if (file.exists()) file.delete() } } writeSafe("ink", inkFile) writeSafe("text", richTextFile) writeSafe("layout", layoutFile) writeSafe("textBoxes", textBoxFile) writeSafe("highlights", highlightFile) Timber.tag("AnnotationSync").d("Unpacked unified bundle.") } else { Timber.tag("AnnotationSync").d("Detected legacy format (Ink only).") inkFile.writeText(jsonString) } } catch (e: Exception) { Timber.e(e, "Error unpacking synced annotation data") } finally { tempDownloadFile.delete() } } else { Timber.tag("AnnotationSync") .d("FAILURE: No bundle found on Drive for $bookId (or download failed)") } } private suspend fun addFileToRecent( uri: Uri, type: FileType, bookId: String, epubBook: EpubBook? = null, customDisplayName: String? = null, isRecent: Boolean, sourceFolderUri: String? = null, bundleResult: CalibreBundleResult? = null ) = withContext(Dispatchers.IO) { val addStart = System.currentTimeMillis() Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent START | type=$type | hasEpubBook=${epubBook != null}") val isNewBook = withContext(Dispatchers.IO) { recentFilesRepository.getFileByBookId(bookId) == null } val fileSize = withContext(Dispatchers.IO) { try { if (uri.scheme == "file") { uri.path?.let { File(it).length() } ?: 0L } else { appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L } else 0L } ?: 0L } } catch (e: Exception) { Timber.e(e, "Failed to get file size for $uri") 0L } } val existingItem = recentFilesRepository.getFileByBookId(bookId) val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( uri, appContext ) ?: "Unknown File" var coverPath: String? = bundleResult?.coverCachePath var title: String? = bundleResult?.title var author: String? = bundleResult?.author var seriesName: String? = bundleResult?.seriesName var seriesIndex: Double? = bundleResult?.seriesIndex var description: String? = bundleResult?.description var bookForMetadata = epubBook if (bookForMetadata == null && bundleResult == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT)) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") val parseStart = System.currentTimeMillis() try { importMutex.withLock { bookForMetadata = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri)?.use { inputStream -> when (type) { FileType.EPUB -> { epubParser.createEpubBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName, parseContent = false ) } FileType.MOBI -> { mobiParser.createMobiBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName, parseContent = false ) } FileType.FB2 -> { fb2Parser.createFb2Book( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName, parseContent = false ) } FileType.ODT, FileType.FODT -> { odtParser.createOdtBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = displayName, isFlat = type == FileType.FODT, parseContent = false ) } else -> { singleFileImporter.importSingleFile( inputStream, type, originalBookNameHint = displayName, bookId = bookId, parseContent = false ) } } } } } Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Metadata parsing completed | elapsed=${System.currentTimeMillis() - parseStart}ms") } catch (e: Exception) { Timber.e( e, "Failed to parse metadata for book: $displayName. Proceeding with basic info." ) bookForMetadata = null } Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent COMPLETE | totalElapsed=${System.currentTimeMillis() - addStart}ms") } val finalBookMetadata = bookForMetadata if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) && finalBookMetadata != null) { title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName author = author ?: finalBookMetadata.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } if (coverPath == null) { finalBookMetadata.coverImage?.let { cover -> coverPath = recentFilesRepository.saveCoverToCache(cover, uri) } } seriesName = seriesName ?: finalBookMetadata.seriesName seriesIndex = seriesIndex ?: finalBookMetadata.seriesIndex description = description ?: finalBookMetadata.description } else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { title = title ?: displayName if (type == FileType.PDF) { try { val pdfiumCore = PdfiumCore(appContext) appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> val pdfDocument = pdfiumCore.newDocument(pfd) val meta = pdfiumCore.getDocumentMeta(pdfDocument) val extractedTitle = meta.title if (!extractedTitle.isNullOrBlank() && title == displayName) { title = extractedTitle } val extractedAuthor = meta.author if (!extractedAuthor.isNullOrBlank() && author == null) { author = extractedAuthor } pdfiumCore.closeDocument(pdfDocument) } } catch (e: Exception) { Timber.e(e, "Failed to extract PDF title using PdfiumCore") } if (coverPath == null) { val pdfCoverGenerator = PdfCoverGenerator(appContext) val coverBitmap = pdfCoverGenerator.generateCover(uri) if (coverBitmap != null) { coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) } } } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) { if (coverPath == null) { var cacheFile: File? = null try { cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}") withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri)?.use { input -> cacheFile.outputStream().use { output -> input.copyTo(output) } } } val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) if (archiveDoc.getPageCount() > 0) { val page = archiveDoc.openPage(0) if (page != null) { val w = page.getPageWidthPoint() val h = page.getPageHeightPoint() if (w > 0 && h > 0) { val targetHeight = 800 val targetWidth = (targetHeight * (w.toFloat() / h.toFloat())).toInt() if (targetWidth > 0) { val bitmap = createBitmap(targetWidth, targetHeight) page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) coverPath = recentFilesRepository.saveCoverToCache(bitmap, uri) } } page.close() } } archiveDoc.close() } catch (e: Exception) { Timber.e(e, "Error generating CBZ cover") } finally { try { if (cacheFile?.exists() == true) { val deleted = cacheFile.delete() if (deleted) Timber.d("Successfully deleted temp archive file: ${cacheFile.name}") } } catch (e: Exception) { Timber.e(e, "Failed to delete temp archive file") } } } } } val newLastModifiedTimestamp = existingItem?.lastModifiedTimestamp ?: System.currentTimeMillis() val newItem = RecentFileItem( bookId = bookId, uriString = uri.toString(), type = type, displayName = displayName, timestamp = System.currentTimeMillis(), coverImagePath = coverPath, title = title, author = author, isAvailable = true, lastModifiedTimestamp = newLastModifiedTimestamp, isDeleted = false, isRecent = isRecent, sourceFolderUri = sourceFolderUri, fileSize = fileSize, seriesName = seriesName, seriesIndex = seriesIndex, description = description ) recentFilesRepository.addRecentFile(newItem) Timber.i("Added/Updated $displayName ($type) to recent files via repository.") if (isNewBook) { uploadNewBookAndMetadata(newItem) } } fun setRecentFilesLimit(limit: Int) { _internalState.update { it.copy(recentFilesLimit = limit) } prefs.edit { putInt(KEY_RECENT_FILES_LIMIT, limit) } } fun setSortOrder(sortOrder: SortOrder) { _internalState.update { it.copy(sortOrder = sortOrder) } prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) } } fun bannerMessageShown() { _internalState.update { it.copy(bannerMessage = null) } } fun showBanner(message: String, isError: Boolean = false) { _internalState.update { it.copy(bannerMessage = BannerMessage(message, isError)) } } fun errorMessageShown() { _internalState.update { it.copy(errorMessage = null) } } private fun sweepOrphanedCache() { viewModelScope.launch(Dispatchers.IO) { Timber.d("Running Cache Sweeper to clean up orphaned temporary files...") try { val cacheDir = appContext.cacheDir if (!cacheDir.exists()) return@launch val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1) val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet() val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet() val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) { ImportedFileCache.activeBookDirName(it) } ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1)) cacheDir.listFiles()?.forEach { file -> val name = file.name if (name.startsWith("temp_") || name.startsWith("sync_bundle_")) { if (file.lastModified() < oneHourAgo) { val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete() if (deleted) Timber.d("Sweeper cleaned old temp file: $name") } } else if (ImportedFileCache.isActiveBookDir(name)) { val legacyBookId = name.removePrefix("imported_file_") if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) { val deleted = file.deleteRecursively() if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache: $name") } } else if (name.startsWith("opds_stream_")) { val bookIdHash = name.removePrefix("opds_stream_") if (bookIdHash !in validStreamHashes) { val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete() if (deleted) Timber.d("Sweeper cleaned orphaned OPDS stream cache for hash: $bookIdHash") } } } val legacyExtractedDir = File(cacheDir, "extracted_epubs") if (legacyExtractedDir.exists()) { val deleted = legacyExtractedDir.deleteRecursively() if (deleted) Timber.d("Sweeper reclaimed storage by deleting legacy extracted_epubs directory") } } catch (e: Exception) { Timber.e(e, "Error during cache sweep: ${e.message}") } } } private fun getFileNameFromUri(uri: Uri, context: Context): String? { var fileName: String? = null if (uri.scheme == "content") { try { val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex != -1) { fileName = it.getString(nameIndex) } } } } catch (e: SecurityException) { Timber.w(e, "Permission denied while resolving display name for URI: $uri") } catch (e: IllegalArgumentException) { Timber.w(e, "Provider rejected display-name query for URI: $uri") } catch (e: RuntimeException) { Timber.w(e, "Unexpected failure while resolving display name for URI: $uri") } } if (fileName == null) { fileName = uri.path val cut = fileName?.lastIndexOf('/') if (cut != -1) { fileName = fileName?.substring(cut!! + 1) } } return fileName ?: uri.lastPathSegment } fun onFilesSelected(uris: List) { if (uris.isEmpty()) return if (uris.size == 1) { onFileSelected(uris.first(), isFromRecent = false) return } viewModelScope.launch { _internalState.update { it.copy( bannerMessage = BannerMessage( message = appContext.getString(R.string.banner_importing_multiple, uris.size), isPersistent = true ), contextualActionItems = emptySet() ) } var importedCount = 0 withContext(Dispatchers.IO) { for (externalUri in uris) { val importResult = prepareBookForImport(externalUri) if (importResult != null) { val (internalUri, bookId, type) = importResult val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" addFileToRecent( uri = internalUri, type = type, bookId = bookId, customDisplayName = displayName, isRecent = false, sourceFolderUri = null, bundleResult = importResult.bundleResult ) importedCount++ } else { val hash = FileHasher.calculateSha256 { appContext.contentResolver.openInputStream(externalUri) } if (hash != null && recentFilesRepository.getFileByBookId(hash) != null) { importedCount++ } } } } _internalState.update { it.copy( bannerMessage = BannerMessage( message = "Imported $importedCount books. You can find them in the Library tab.", isPersistent = false ) ) } Timber.tag("BulkImport").i("Bulk import complete. $importedCount files processed.") } } fun onFileSelected(uri: Uri, isFromRecent: Boolean = false, isExternalIntent: Boolean = false) { if (isFromRecent) { Timber.i("Opening recent file: $uri") viewModelScope.launch { val item = recentFilesRepository.getFileByUri(uri.toString()) if (item != null) { openBook(uri, item.bookId, item.type, item.displayName) } else { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_recent_item_not_found)) } } } } else { Timber.i("Importing new file: $uri") importExternalFile(uri, isExternalIntent) } } private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) { _internalState.update { it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) } viewModelScope.launch { try { val importResult = prepareBookForImport(externalUri) if (importResult != null) { val (internalUri, bookId, type) = importResult if (isExternalIntent) { externalOpenedBookId = bookId } val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" openBook( internalUri, bookId = bookId, type = type, originalDisplayName = displayName, bundleResult = importResult.bundleResult ) } else { val hash = FileHasher.calculateSha256 { appContext.contentResolver.openInputStream(externalUri) } if (hash != null) { val existingItem = recentFilesRepository.getFileByBookId(hash) if (existingItem != null) { Timber.i("Re-selected an existing book. Opening it.") onRecentFileClicked(existingItem) _internalState.update { it.copy(isLoading = false) } return@launch } } _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } } } catch (e: SecurityException) { Timber.e(e, "Permission denied while importing URI: $externalUri") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } } catch (e: IllegalArgumentException) { Timber.e(e, "Provider rejected URI import for: $externalUri") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } } catch (e: RuntimeException) { Timber.e(e, "Unexpected import failure for URI: $externalUri") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } } } } fun saveHighlights(bookId: String, highlightsJson: String) { viewModelScope.launch { val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri if (currentBookUri != null) { recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item -> recentFilesRepository.updateHighlights(item.bookId, highlightsJson) } } else if (bookId.isNotBlank()) { recentFilesRepository.updateHighlights(bookId, highlightsJson) } } } val reflowWorkInfo: Flow = WorkManager.getInstance(appContext).getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) .map { list -> list.find { !it.state.isFinished } ?: list.firstOrNull() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) fun switchToFileSeamlessly(item: RecentFileItem, syncPosition: Int) { viewModelScope.launch { Timber.tag("FileSwitch") .d("Starting seamless switch to ${item.bookId}, position: $syncPosition") val stateUpdateDeferred = CompletableDeferred() pendingSwitchDeferred = stateUpdateDeferred _internalState.update { it.copy(isLoading = true, errorMessage = null) } val uri = item.getUri() ?: run { _internalState.update { it.copy( isLoading = false, errorMessage = appContext.getString(R.string.error_file_location_not_found) ) } stateUpdateDeferred.complete(false) pendingSwitchDeferred = null return@launch } val type = item.type val bookId = item.bookId if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { persistReaderSession(bookId, type) _internalState.update { it.copy( selectedEpubUri = null, selectedEpubBook = null, selectedFileType = type, selectedBookId = bookId, selectedPdfUri = uri, initialPageInBook = syncPosition, initialBookmarksJson = item.bookmarksJson, isLoading = false ) } delay(50) addFileToRecent( uri, type, bookId, customDisplayName = item.displayName, isRecent = true, sourceFolderUri = null ) Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event") _navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri)) stateUpdateDeferred.complete(true) } else { persistReaderSession(bookId, type) try { val epubBook = restoreEpubReaderBook(type, bookId, item.displayName, uri) _internalState.update { it.copy( selectedPdfUri = null, selectedFileType = type, selectedBookId = bookId, selectedEpubUri = uri, selectedEpubBook = epubBook, initialLocator = Locator( chapterIndex = syncPosition, blockIndex = 0, charOffset = 0 ), initialCfi = null, initialBookmarksJson = item.bookmarksJson, initialHighlightsJson = item.highlightsJson, isLoading = false ) } delay(50) addFileToRecent( uri, type, bookId, epubBook, item.displayName, isRecent = true, sourceFolderUri = null ) Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") _navigationEvent.send(NavigationEvent("epub_reader", bookId, uri)) stateUpdateDeferred.complete(true) } catch (e: Exception) { Timber.e(e, "Failed to switch seamlessly to $type book: $bookId") _internalState.update { it.copy( isLoading = false, errorMessage = appContext.getString(R.string.error_load_file, e.message), selectedFileType = null ) } stateUpdateDeferred.complete(false) } } } } fun generateAndImportReflowFile( pdfBookId: String, pdfUri: Uri, originalTitle: String, autoOpenPage: Int? = null ) { Timber.tag("PdfToMdPerf") .d("generateAndImportReflowFile START | pdfBookId=$pdfBookId | pdfUri=$pdfUri") val reflowBookId = "${pdfBookId}_reflow" viewModelScope.launch { val existing = recentFilesRepository.getFileByBookId(reflowBookId) if (existing != null) { if (autoOpenPage != null) { switchToFileSeamlessly(existing, autoOpenPage) } else { onRecentFileClicked(existing) } return@launch } val workManager = WorkManager.getInstance(appContext) val inputData = androidx.work.Data.Builder().putString(ReflowWorker.KEY_BOOK_ID, pdfBookId) .putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString()) .putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle).build() val request = OneTimeWorkRequestBuilder().setInputData(inputData) .addTag(ReflowWorker.WORK_NAME).addTag("book_$pdfBookId").build() workManager.enqueueUniqueWork( "reflow_$pdfBookId", ExistingWorkPolicy.KEEP, request ) if (autoOpenPage != null) { launch { importMutex.withLock { val finalInfo = workManager.getWorkInfoByIdFlow(request.id).filterNotNull() .first { it.state.isFinished } if (finalInfo.state == WorkInfo.State.SUCCEEDED) { var retries = 0 var newItem = recentFilesRepository.getFileByBookId(reflowBookId) while (newItem == null && retries < 10) { delay(200) newItem = recentFilesRepository.getFileByBookId(reflowBookId) retries++ } if (newItem != null) { switchToFileSeamlessly(newItem, autoOpenPage) } else { showBanner(appContext.getString(R.string.error_load_generated_text_view), true) } } else { showBanner(appContext.getString(R.string.error_text_view_generation_failed), true) } } } } } } private suspend fun cleanupBookDataLocally(bookId: String) { pdfTextRepository.clearBookText(bookId) clearImportedFileCache(bookId) bookCacheDao.deleteEntireBookCache(bookId) } private fun clearImportedFileCache(bookId: String) { try { ImportedFileCache.clearBookCache(appContext, bookId) Timber.tag("FileCleanup").d("Deleted imported cache for $bookId") } catch (e: Exception) { Timber.e(e, "Failed to clear imported file cache for $bookId") } } private fun openBook( uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null ) { val openBookStartTime = System.currentTimeMillis() ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") Timber.tag("FileOpenPerf") .d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName") if (_internalState.value.isTabsEnabled && type == FileType.PDF) { val currentTabs = _internalState.value.openTabIds.toMutableList() if (!currentTabs.contains(bookId)) { if (currentTabs.size >= 20) { viewModelScope.launch(Dispatchers.Main) { showBanner("Maximum of 20 tabs allowed. Please close a tab first.", isError = true) } return } currentTabs.add(bookId) } prefs.edit { putString(KEY_OPEN_TAB_IDS, JSONArray(currentTabs).toString()) putString(KEY_ACTIVE_TAB, bookId) } _internalState.update { it.copy(openTabIds = currentTabs, activeTabBookId = bookId) } } if (uri.scheme != "opds-pse") { try { if (uri.scheme == "file") { uri.path?.let { val file = File(it) val size = file.length() val name = file.name Timber.tag("FileOpenPerf").d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") } } else { val cursor = appContext.contentResolver.query(uri, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown" Timber.tag("FileOpenPerf") .d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}") } } } } catch (e: Exception) { Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details") } } viewModelScope.launch { _internalState.update { it.copy( selectedPdfUri = null, selectedEpubUri = null, selectedBookId = bookId, selectedEpubBook = null, selectedFileType = type, isLoading = true, errorMessage = null, initialLocator = null, initialPageInBook = null ) } if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) Timber.tag("FileOpenPerf") .d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") _internalState.update { it.copy( selectedPdfUri = uri, initialPageInBook = recentItem?.lastPage, initialBookmarksJson = recentItem?.bookmarksJson, isLoading = false ) } ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") persistReaderSession(bookId, type) addFileToRecent( uri, type, bookId, customDisplayName = originalDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) if (!suppressNavigation) { Timber.tag("FileSwitch").d("PDF state updated, emitting navigation event") _navigationEvent.send(NavigationEvent("pdf_viewer", bookId, uri)) } else { Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition") } } } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) Timber.tag("FileOpenPerf") .d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") val locator = if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { Locator( chapterIndex = recentItem.lastChapterIndex, blockIndex = recentItem.locatorBlockIndex, charOffset = recentItem.locatorCharOffset ) } else { null } _internalState.update { it.copy( selectedEpubUri = uri, initialLocator = locator, initialCfi = recentItem?.lastPositionCfi, initialBookmarksJson = recentItem?.bookmarksJson, initialHighlightsJson = recentItem?.highlightsJson, ) } ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms") persistReaderSession(bookId, type) if (!suppressNavigation) { Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") _navigationEvent.send(NavigationEvent("epub_reader", bookId, uri)) } when (type) { FileType.EPUB -> { loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.MOBI -> { loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.FB2 -> { loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.ODT, FileType.FODT -> { loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult) } else -> { loadSingleFile( uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult ) } } } } } } private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } Timber.d("Starting FB2 parsing for URI: $uri") try { val fb2Book = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) throw Exception("Could not open input stream") fb2Parser.createFb2Book( inputStream = inputStream, bookId = bookId, originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.fb2" ) } } Timber.i("FB2 parsing successful. Title: ${fb2Book.title}") Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) } } catch (e: Exception) { Timber.e(e, "Error parsing FB2 for URI: $uri") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_load_fb2, e.message), isLoading = false) } } } } private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } Timber.d("Starting ODT parsing for URI: $uri") try { val odtBook = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) throw Exception("Could not open input stream") odtParser.createOdtBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: if (isFlat) "unknown.fodt" else "unknown.odt", isFlat = isFlat ) } } Timber.i("ODT parsing successful. Title: ${odtBook.title}") Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) } } catch (e: Exception) { Timber.e(e, "Error parsing ODT for URI: $uri") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_load_file, e.message), isLoading = false) } } } } private fun loadSingleFile( uri: Uri, bookId: String, type: FileType, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null ) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } Timber.d("Starting Single File import ($type) for URI: $uri") try { val epubBook = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) { throw Exception("Could not open input stream for URI") } singleFileImporter.importSingleFile( inputStream, type, originalBookNameHint = customDisplayName ?: getFileNameFromUri( uri, appContext ) ?: "unknown_doc", bookId = bookId ) } } Timber.tag("FileOpenPerf") .d("[$bookId] loadSingleFile: importSingleFile completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") Timber.i("Import successful ($type). Title: ${epubBook.title}") addFileToRecent( uri, type, bookId, epubBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } Timber.tag("FileOpenPerf") .d("[$bookId] loadSingleFile COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") } catch (e: Exception) { Timber.e(e, "Error parsing file ($type) for URI: $uri") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_load_file, e.message), isLoading = false) } } } } fun setRenderMode(newMode: RenderMode) { _internalState.update { it.copy(renderMode = newMode) } prefs.edit { putString(KEY_RENDER_MODE, newMode.name) } } private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? { val mimeType = try { context.contentResolver.getType(uri) } catch (e: SecurityException) { Timber.w(e, "Permission denied while resolving MIME type for URI: $uri") null } catch (e: IllegalArgumentException) { Timber.w(e, "Provider rejected MIME type lookup for URI: $uri") null } catch (e: RuntimeException) { Timber.w(e, "Unexpected failure while resolving MIME type for URI: $uri") null } val fileName = getFileNameFromUri(uri, context) Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") return when (mimeType) { "application/vnd.oasis.opendocument.text" -> FileType.ODT "application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> { if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null } "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> { if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null } "application/x-cb7", "application/x-7z-compressed" -> { if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null } "application/pdf" -> FileType.PDF "application/epub+zip" -> FileType.EPUB "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI "text/markdown", "text/x-markdown" -> FileType.MD "text/html", "application/xhtml+xml" -> FileType.HTML "text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json", "application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin", "text/javascript", "application/javascript", "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML "text/plain" -> { resolveFileTypeFromName(fileName) ?: FileType.TXT } else -> { resolveFileTypeFromName(fileName) } } } private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } Timber.d("Starting MOBI parsing for URI: $uri") try { val mobiAsEpubBook = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) { throw Exception("Could not open input stream for URI") } mobiParser.createMobiBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.mobi" ) } } if (mobiAsEpubBook != null) { Timber.i("MOBI parsing successful. Title: ${mobiAsEpubBook.title}") addFileToRecent( uri, FileType.MOBI, bookId, mobiAsEpubBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false) } } else { throw Exception( if (MobiParser.isNativeParserAvailable) { "MobiParser returned null. The file might be DRM-protected or invalid." } else { MobiParser.nativeParserUnavailableMessage() } ) } } catch (e: Exception) { Timber.e(e, "Error parsing MOBI for URI: $uri") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_load_mobi, e.message), isLoading = false) } } } } private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START") viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } } Timber.d("Starting EPUB parsing for URI: $uri") try { val epubBook = withContext(Dispatchers.IO) { appContext.contentResolver.openInputStream(uri).use { inputStream -> if (inputStream == null) { throw Exception("Could not open input stream for URI") } epubParser.createEpubBook( inputStream = inputStream, bookId = bookId, originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.epub" ) } } Timber.i("EPUB parsing successful. Title: ${epubBook.title}") Timber.tag("FileOpenPerf") .d("[$bookId] loadEpub: createEpubBook completed | chapters=${epubBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( uri, FileType.EPUB, bookId, epubBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } Timber.tag("FileOpenPerf") .d("[$bookId] loadEpub COMPLETE | totalElapsed=${System.currentTimeMillis() - loadStart}ms") } catch (e: Exception) { Timber.e(e, "Error parsing EPUB for URI: $uri") _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_load_epub, e.message), isLoading = false) } } } } fun saveEpubReadingPosition( uri: Uri, locator: Locator, cfiForWebView: String?, progress: Float ) { Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator") viewModelScope.launch { recentFilesRepository.getFileByUri(uri.toString())?.let { _ -> recentFilesRepository.updateEpubReadingPosition( uriString = uri.toString(), locator = locator, cfiForWebView = cfiForWebView, progress = progress ) } } } fun saveBookmarks(bookId: String, bookmarksJson: String) { Timber.d("saveBookmarks called. bookId=$bookId, bookmarksJson=$bookmarksJson") viewModelScope.launch { val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri Timber.d("saveBookmarks: currentBookUri is $currentBookUri") if (currentBookUri != null) { recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item -> Timber.d( "saveBookmarks: Found item by URI. Updating bookmarks for bookId=${item.bookId}" ) recentFilesRepository.updateBookmarks(item.bookId, bookmarksJson) } } else if (bookId.isNotBlank()) { Timber.d( "saveBookmarks: URI is null, but bookId is present. Updating bookmarks for bookId=$bookId" ) recentFilesRepository.updateBookmarks(bookId, bookmarksJson) } else { Timber.w( "PdfBookmarkDebug: saveBookmarks called with no active URI and empty bookId." ) } } } fun savePdfReadingPosition(page: Int, totalPages: Int) { val currentPdfUri = _internalState.value.selectedPdfUri if (currentPdfUri != null) { val progress = if (totalPages > 0) { ((page + 1).toFloat() / totalPages.toFloat()) * 100f } else { 0f } Timber.tag("PdfPositionDebug").i("ViewModel: Save request triggered | Page: $page | Total: $totalPages | Progress: $progress | URI: ${currentPdfUri.lastPathSegment}") viewModelScope.launch { recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.updatePdfReadingPosition( uriString = currentPdfUri.toString(), page = page, progress = progress ) } ?: run { Timber.tag("PdfPositionDebug").e("ViewModel: Save aborted. Could not resolve file item from URI in DB.") } } } else { Timber.tag("PdfPositionDebug").w("ViewModel: Save aborted. No selectedPdfUri found in state.") } } fun exportLogsToFile(activityContext: Context) { viewModelScope.launch(Dispatchers.IO) { try { Timber.d("Generating logcat dump for debugging...") val logFile = File(appContext.cacheDir, "debug_logs_${System.currentTimeMillis()}.txt") val process = Runtime.getRuntime().exec("logcat -d -v threadtime -t 5000") process.inputStream.bufferedReader().use { reader -> logFile.writeText(reader.readText()) } val authority = "${appContext.packageName}.provider" val uri = androidx.core.content.FileProvider.getUriForFile(appContext, authority, logFile) val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_TITLE, "App Debug Logs") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } val chooser = Intent.createChooser(intent, "Export Debug Logs") if (activityContext !is android.app.Activity) { chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } withContext(Dispatchers.Main) { activityContext.startActivity(chooser) } } catch (e: Exception) { Timber.e(e, "Failed to export logs") withContext(Dispatchers.Main) { showBanner("Failed to export logs", isError = true) } } } } fun refreshLibrary() { val syncEnabled = _internalState.value.isSyncEnabled val hasFolder = _internalState.value.syncedFolders.isNotEmpty() if (!syncEnabled && !hasFolder) { Timber.d("Refresh skipped: No sync methods active.") _internalState.update { it.copy(isRefreshing = false) } return } viewModelScope.launch { _internalState.update { it.copy(isRefreshing = true) } try { if (syncEnabled) { syncWithCloud(showBanner = false).join() } if (hasFolder) { syncFolderMetadata(showFeedback = true) } } catch (e: Exception) { Timber.e(e, "Refresh failed") _internalState.update { it.copy(isRefreshing = false) } } finally { if (!hasFolder) { _internalState.update { it.copy(isRefreshing = false) } } } } } fun clearBookCache() { viewModelScope.launch { bookCacheDao.clearAllCache() WorkManager.getInstance(getApplication()) .cancelAllWorkByTag(BookProcessingWorker.WORK_TAG) Timber.i("Book cache has been cleared and all processing workers cancelled.") } } fun onRecentFileClicked(item: RecentFileItem) { ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}") val currentSelection = _internalState.value.contextualActionItems if (currentSelection.isNotEmpty()) { Timber.d("Toggling selection for: ${item.displayName}") val newSelection = if (currentSelection.any { it.bookId == item.bookId }) { currentSelection.filterNot { it.bookId == item.bookId }.toSet() } else { currentSelection + item } _internalState.update { it.copy(contextualActionItems = newSelection) } Timber.d("New selection size: ${newSelection.size}") } else { if (item.sourceFolderUri != null && item.uriString != null) { viewModelScope.launch { val exists = try { val uri = item.uriString.toUri() DocumentFile.fromSingleUri(appContext, uri)?.exists() == true } catch (_: Exception) { false } if (!exists) { Timber.tag("FolderSync") .i("LazyCleanup: File ${item.displayName} missing. Removing.") recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) showBanner(appContext.getString(R.string.banner_file_deleted_from_folder)) return@launch } Timber.d("Recent file clicked (opening): ${item.displayName}") if (item.isAvailable) { item.getUri()?.let { uri -> openBook(uri, item.bookId, item.type, item.displayName) } ?: run { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found)) } } } else { downloadBook(item, openWhenComplete = true) } } return } Timber.d("Recent file clicked (opening): ${item.displayName}") if (item.isAvailable) { item.getUri()?.let { uri -> openBook(uri, item.bookId, item.type, item.displayName) } ?: run { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found)) } return } } else { downloadBook(item, openWhenComplete = true) } } } private fun uploadNewBookAndMetadata(book: RecentFileItem) { if (!uiState.value.isSyncEnabled) return viewModelScope.launch { _internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) } try { val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch book.getUri()?.path?.let { path -> val file = File(path) if (file.exists()) { Timber.d("Uploading newly added book content: ${book.displayName}") val uploadedFile = googleDriveRepository.uploadFile( accessToken, book.bookId, file, book.type ) if (uploadedFile != null) { Timber.d("Upload successful, now syncing metadata for ${book.bookId}") val latestBookState = recentFilesRepository.getFileByBookId(book.bookId) if (latestBookState != null) { uploadSingleBookMetadata(latestBookState) } else { uploadSingleBookMetadata(book) } } else { Timber.e("Google Drive upload returned null for ${book.bookId}") } } else { Timber.w("File for new book upload does not exist at path: $path") } } } catch (e: Exception) { Timber.e(e, "Failed to upload new book content for bookId: ${book.bookId}") } finally { _internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds - book.bookId) } } } } fun onRecentItemLongPress(item: RecentFileItem) { val currentSelection = _internalState.value.contextualActionItems Timber.d( "Long press on: ${item.displayName}. Current selection size: ${currentSelection.size}" ) if (currentSelection.none { it.bookId == item.bookId }) { _internalState.update { it.copy(contextualActionItems = currentSelection + item) } } Timber.d("New selection size: ${_internalState.value.contextualActionItems.size}") } fun selectAllRecentFiles() { val currentVisible = uiState.value.recentFiles.filter { it.isRecent }.toSet() _internalState.update { state -> if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { state.copy(contextualActionItems = emptySet()) } else { state.copy(contextualActionItems = currentVisible) } } } fun selectAllLibraryFiles() { val currentVisible = uiState.value.allRecentFiles.toSet() _internalState.update { state -> if (state.contextualActionItems.containsAll(currentVisible) && currentVisible.isNotEmpty()) { state.copy(contextualActionItems = emptySet()) } else { state.copy(contextualActionItems = currentVisible) } } } fun clearContextualAction() { Timber.d("Clearing contextual action mode.") if (_internalState.value.contextualActionItems.isNotEmpty()) { _internalState.update { it.copy(contextualActionItems = emptySet()) } } } fun showCreateShelfDialog() { _internalState.update { it.copy(showCreateShelfDialog = true) } } fun handleExternalFilePrompt(bookId: String, keep: Boolean, dontAskAgain: Boolean) { if (dontAskAgain) { val newBehavior = if (keep) "KEEP" else "DELETE" setExternalFileBehavior(newBehavior) } if (!keep) { deleteBookPermanently(bookId) } _internalState.update { it.copy(showExternalFileSavePromptFor = null) } } fun setExternalFileBehavior(behavior: String) { prefs.edit { putString(KEY_EXTERNAL_FILE_BEHAVIOR, behavior) } _internalState.update { it.copy(externalFileBehavior = behavior) } } fun dismissCreateShelfDialog() { _internalState.update { it.copy(showCreateShelfDialog = false) } } fun createShelf(name: String) { val shelfId = UUID.randomUUID().toString() val now = System.currentTimeMillis() val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return viewModelScope.launch { recentFilesRepository.addShelf(shelf) dismissCreateShelfDialog() syncShelfChangeToFirestore(shelfId) } } fun setMainScreenPage(page: Int) { val sanitizedPage = page.coerceIn(0, 1) if (_internalState.value.mainScreenStartPage == sanitizedPage) return _internalState.update { it.copy(mainScreenStartPage = sanitizedPage) } persistLibraryLandingState() } fun setLibraryScreenPage(page: Int) { val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3 val sanitizedPage = page.coerceIn(0, maxLibraryPage) if (_internalState.value.libraryScreenStartPage == sanitizedPage) return _internalState.update { it.copy(libraryScreenStartPage = sanitizedPage) } persistLibraryLandingState() } fun navigateToShelf(id: String) { _internalState.update { it.copy(viewingShelfId = id, mainScreenStartPage = 1, libraryScreenStartPage = 1) } persistLibraryLandingState() } fun showRenameShelfDialog(shelfId: String) { _internalState.update { it.copy(showRenameShelfDialogFor = shelfId) } } fun dismissRenameShelfDialog() { _internalState.update { it.copy(showRenameShelfDialogFor = null) } } fun showDeleteShelfDialog(shelfId: String) { _internalState.update { it.copy(showDeleteShelfDialogFor = shelfId) } } fun dismissDeleteShelfDialog() { _internalState.update { it.copy(showDeleteShelfDialogFor = null) } } fun renameShelf(shelfId: String, newName: String) { val cleanName = SharedLibraryEditor.cleanShelfName(newName) if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) { dismissRenameShelfDialog() return } viewModelScope.launch { recentFilesRepository.renameShelf(shelfId, cleanName) syncShelfChangeToFirestore(shelfId) _internalState.update { it.copy(viewingShelfId = shelfId) } persistLibraryLandingState() dismissRenameShelfDialog() } } fun deleteShelf(shelfId: String) { if (!SharedLibraryEditor.canMutateShelf(shelfId)) { dismissDeleteShelfDialog() return } viewModelScope.launch { _internalState.update { it.copy(viewingShelfId = null, isAddingBooksToShelf = false, showDeleteShelfDialogFor = null) } persistLibraryLandingState() recentFilesRepository.deleteShelf(shelfId) syncShelfChangeToFirestore(shelfId) } } fun unselectShelf() { _internalState.update { it.copy(viewingShelfId = null, isAddingBooksToShelf = false) } persistLibraryLandingState() } fun navigateBackFromShelf() { val currentShelf = uiState.value.shelves.find { it.id == _internalState.value.viewingShelfId } val parentShelfId = currentShelf?.takeIf { it.type == ShelfType.FOLDER }?.parentShelfId if (parentShelfId != null) { _internalState.update { it.copy(viewingShelfId = parentShelfId, isAddingBooksToShelf = false) } persistLibraryLandingState() } else { unselectShelf() } } fun removeContextualItemsFromShelf() { val shelfId = _internalState.value.viewingShelfId if (!SharedLibraryEditor.canMutateShelf(shelfId)) { clearContextualAction() return } val targetShelfId = shelfId ?: return val bookIdsToRemove = SharedLibraryEditor.cleanBookIds(_internalState.value.contextualActionItems.map { it.bookId }) if (bookIdsToRemove.isEmpty()) { clearContextualAction() return } viewModelScope.launch { recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList()) clearContextualAction() syncShelfChangeToFirestore(targetShelfId) } } fun onShelfClick(shelf: Shelf) { if (_internalState.value.contextualActionShelfIds.isNotEmpty()) { toggleShelfSelection(shelf) } else { navigateToShelf(shelf.id) } } private fun toggleShelfSelection(shelf: Shelf) { if (shelf.type != ShelfType.MANUAL) return _internalState.update { state -> val currentSelection = state.contextualActionShelfIds val newSelection = if (shelf.id in currentSelection) { currentSelection - shelf.id } else { currentSelection + shelf.id } state.copy(contextualActionShelfIds = newSelection) } } fun onShelfLongPress(shelf: Shelf) { if (shelf.type != ShelfType.MANUAL || shelf.id == "unshelved") return val currentSelection = _internalState.value.contextualActionShelfIds if (shelf.id !in currentSelection) { _internalState.update { it.copy(contextualActionShelfIds = currentSelection + shelf.id) } } } fun clearShelfContextualAction() { if (_internalState.value.contextualActionShelfIds.isNotEmpty()) { _internalState.update { it.copy(contextualActionShelfIds = emptySet()) } } } fun deleteSelectedShelves() { val shelvesToDelete = _internalState.value.contextualActionShelfIds .filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) } if (shelvesToDelete.isEmpty()) { clearShelfContextualAction() return } viewModelScope.launch { shelvesToDelete.forEach { shelfId -> recentFilesRepository.deleteShelf(shelfId) syncShelfChangeToFirestore(shelfId) } clearShelfContextualAction() } } fun showAddBooksToShelf() { _internalState.update { it.copy( isAddingBooksToShelf = true, addBooksSource = AddBooksSource.UNSHELVED, booksSelectedForAdding = emptySet() ) } persistLibraryLandingState() } private fun syncShelfChangeToFirestore(shelfId: String) { if (!uiState.value.isSyncEnabled) return val currentUser = uiState.value.currentUser ?: return viewModelScope.launch(Dispatchers.IO) { val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext) val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId) val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync() .filter { it.isManualOnlyReaderFile() } .mapTo(mutableSetOf()) { it.bookId } val bookIds = crossRefs.map { it.bookId } .filterNot { it in manualOnlyBookIds } val shelfMetadata = ShelfMetadata( name = shelf.name, bookIds = bookIds, isDeleted = shelf.isDeleted, lastModifiedTimestamp = shelf.updatedAt ) val deviceId = getInstallationId() firestoreRepository.syncShelf(currentUser.uid, shelfMetadata, deviceId) } } fun dismissAddBooksToShelf() { _internalState.update { it.copy( isAddingBooksToShelf = false, booksSelectedForAdding = emptySet(), addBooksSource = AddBooksSource.UNSHELVED ) } persistLibraryLandingState() } fun addBooksToShelf(shelfId: String) { val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding) if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) { dismissAddBooksToShelf() return } viewModelScope.launch { recentFilesRepository.addBooksToShelf(shelfId, bookIdsToAdd.toList()) syncShelfChangeToFirestore(shelfId) _internalState.update { it.copy(isAddingBooksToShelf = false, booksSelectedForAdding = emptySet()) } persistLibraryLandingState() } } fun setAddBooksSource(source: AddBooksSource) { _internalState.update { it.copy(addBooksSource = source) } prefs.edit { putString(KEY_ADD_BOOKS_SOURCE, source.name) } } private fun persistLibraryLandingState() { val state = _internalState.value val resolvedUiState = uiState.value prefs.edit { putInt(KEY_MAIN_SCREEN_START_PAGE, state.mainScreenStartPage) putInt(KEY_LIBRARY_SCREEN_START_PAGE, state.libraryScreenStartPage) putString(KEY_LAST_VIEWING_SHELF_ID, resolvedUiState.viewingShelfId) putBoolean(KEY_LAST_ADDING_BOOKS_TO_SHELF, resolvedUiState.isAddingBooksToShelf) } } fun toggleBookSelectionForAdding(bookId: String) { _internalState.update { state -> val currentSelection = state.booksSelectedForAdding val newSelection = if (bookId in currentSelection) { currentSelection - bookId } else { currentSelection + bookId } state.copy(booksSelectedForAdding = newSelection) } } fun deleteContextualItemsPermanently() { val itemsToRemove = _internalState.value.contextualActionItems if (itemsToRemove.isNotEmpty()) { _internalState.update { it.copy(contextualActionItems = emptySet()) } viewModelScope.launch { val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions( appContext ) val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null } withContext(Dispatchers.IO) { if (folderBooks.isNotEmpty()) { Timber.d("Processing ${folderBooks.size} folder books for deletion.") val idsToDeleteLocally = mutableListOf() folderBooks.forEach { item -> idsToDeleteLocally.add(item.bookId) cleanupBookDataLocally(item.bookId) clearImportedFileCache(item.bookId) if (item.uriString != null) { try { val fileUri = item.uriString.toUri() val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri) if (fileDoc != null && fileDoc.exists()) { if (fileDoc.delete()) { Timber.i("Physically deleted folder file: ${item.displayName}") } else { Timber.e("Failed to delete folder file via SAF: ${item.displayName}") } } } catch (e: Exception) { Timber.e(e, "Error deleting physical file for ${item.bookId}") } } if (item.sourceFolderUri != null) { try { val rootUri = item.sourceFolderUri.toUri() val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri) if (rootDoc != null) { val hiddenMeta = rootDoc.findFile(".${item.bookId}.json") val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json") 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}") } } } recentFilesRepository.deleteFilePermanently(idsToDeleteLocally) } if (managedBooks.isNotEmpty()) { val currentUser = uiState.value.currentUser if (canSync && currentUser != null) { _internalState.update { it.copy( isLoading = true, bannerMessage = BannerMessage(appContext.getString(R.string.banner_deleting_all_devices)) ) } try { val accessToken = googleDriveRepository.getAccessToken(appContext) ?: throw Exception( "No token" ) val deviceId = getInstallationId() val remoteFiles = googleDriveRepository.getFiles(accessToken)?.files.orEmpty() .associateBy { it.name } for (item in managedBooks) { if (item.isManualOnlyReaderFile()) { cleanupBookDataLocally(item.bookId) recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) continue } recentFilesRepository.markAsDeleted(listOf(item.bookId)) cleanupBookDataLocally(item.bookId) firestoreRepository.syncBookMetadata( currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId ) val fileExtension = item.type.name.lowercase() val fileName = "${item.bookId}.$fileExtension" remoteFiles[fileName]?.id?.let { fileId -> Timber.d("Deleting from Drive: $fileName") googleDriveRepository.deleteDriveFile(accessToken, fileId) } recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) } _internalState.update { it.copy( isLoading = false, bannerMessage = BannerMessage(appContext.getString(R.string.banner_deletion_complete)) ) } } catch (e: Exception) { Timber.e(e, "Error during permanent deletion") recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) managedBooks.forEach { item -> cleanupBookDataLocally(item.bookId) } _internalState.update { it.copy( isLoading = false, errorMessage = appContext.getString(R.string.error_cloud_sync_failed_deleted_locally) ) } } } else { recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) managedBooks.forEach { item -> cleanupBookDataLocally(item.bookId) } } } } val totalRemoved = folderBooks.size + managedBooks.size _internalState.update { it.copy( isLoading = false, bannerMessage = BannerMessage(appContext.resources.getQuantityString(R.plurals.banner_books_removed_library, totalRemoved, totalRemoved)) ) } } } else { Timber.w("Attempted to remove contextual items, but none were selected.") } } fun navigateToFolderSync() { setMainScreenPage(1) setLibraryScreenPage(2) } override fun onCleared() { super.onCleared() prefs.unregisterOnSharedPreferenceChangeListener(prefsListener) firestoreRepository.removeListener(feedbackListener) panelDetector?.close() panelDetector = null speechBubbleDetector?.close() speechBubbleDetector = null speechBubbleCache.clear() speechBubbleDetectionJobs.clear() ttsController.release() 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") } } fun clearReflowCache() { viewModelScope.launch(Dispatchers.IO) { val reflowDir = File(appContext.cacheDir, "reflow_cache") if (reflowDir.exists()) { reflowDir.deleteRecursively() } val imagesDir = File(appContext.cacheDir, "reflow_images") if (imagesDir.exists()) { imagesDir.deleteRecursively() } val allFiles = recentFilesRepository.getAllFilesForSync() val reflowBooks = allFiles.filter { it.bookId.endsWith("_reflow") } if (reflowBooks.isNotEmpty()) { val reflowBookIds = reflowBooks.map { it.bookId } reflowBookIds.forEach { bookId -> cleanupBookDataLocally(bookId) } recentFilesRepository.deleteFilePermanently(reflowBookIds) } withContext(Dispatchers.Main) { showBanner(appContext.getString(R.string.banner_reflow_cache_cleared)) } } } fun updateCustomName(bookId: String, newName: String?) { viewModelScope.launch { val item = recentFilesRepository.getFileByBookId(bookId) if (item != null) { val updatedItem = item.copy(customName = newName, lastModifiedTimestamp = System.currentTimeMillis()) recentFilesRepository.addRecentFile(updatedItem) if (uiState.value.isSyncEnabled) { uploadSingleBookMetadata(updatedItem) } if (updatedItem.sourceFolderUri != null) { launch(Dispatchers.IO) { recentFilesRepository.syncLocalMetadataToFolder(bookId) } } } } } fun closeAllTabs() { Timber.tag("PdfTabSync").i("ViewModel: closeAllTabs called") prefs.edit { remove(KEY_OPEN_TAB_IDS) remove(KEY_ACTIVE_TAB) } _internalState.update { it.copy(openTabIds = emptyList(), activeTabBookId = null) } clearSelectedFile() } fun setStrictFileFilter(enabled: Boolean) { prefs.edit { putBoolean(KEY_USE_STRICT_FILE_FILTER, enabled) } _internalState.update { it.copy(useStrictFileFilter = enabled) } } fun setScreenCaptureProtectionEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) } _internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) } } private fun loadCustomAppThemes(prefs: SharedPreferences): List { val jsonString = prefs.getString(KEY_CUSTOM_APP_THEMES, "[]") ?: "[]" val themes = mutableListOf() try { val jsonArray = JSONArray(jsonString) for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) themes.add( CustomAppTheme( id = obj.getString("id"), name = obj.getString("name"), seedColor = androidx.compose.ui.graphics.Color(obj.getInt("seedColor")) ) ) } } catch (e: Exception) { Timber.e(e, "Failed to parse custom app themes") } return themes } fun setAppThemeMode(mode: AppThemeMode) { _internalState.update { it.copy(appThemeMode = mode) } prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) } } fun setAppContrastOption(option: AppContrastOption) { _internalState.update { it.copy(appContrastOption = option) } prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } } fun setAppTextDimFactorLight(factor: Float) { _internalState.update { it.copy(appTextDimFactorLight = factor) } prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) } } fun setAppTextDimFactorDark(factor: Float) { _internalState.update { it.copy(appTextDimFactorDark = factor) } prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) } } fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) { _internalState.update { it.copy(appSeedColor = color) } prefs.edit { if (color == null) { remove(KEY_APP_SEED_COLOR) } else { putInt(KEY_APP_SEED_COLOR, (color).toArgb()) } } } fun addCustomAppTheme(theme: CustomAppTheme) { val current = _internalState.value.customAppThemes.filter { it.id != theme.id } + theme _internalState.update { it.copy(customAppThemes = current) } saveCustomAppThemes(current) setAppSeedColor(theme.seedColor) } fun deleteCustomAppTheme(themeId: String) { val current = _internalState.value.customAppThemes.filter { it.id != themeId } _internalState.update { it.copy(customAppThemes = current) } saveCustomAppThemes(current) if (_internalState.value.appSeedColor != null && !current.any { it.seedColor == _internalState.value.appSeedColor }) { setAppSeedColor(null) } } private fun saveCustomAppThemes(themes: List) { val jsonArray = JSONArray() themes.forEach { theme -> val obj = JSONObject().apply { put("id", theme.id) put("name", theme.name) put("seedColor", (theme.seedColor).toArgb()) } jsonArray.put(obj) } prefs.edit { putString(KEY_CUSTOM_APP_THEMES, jsonArray.toString()) } } suspend fun getAuthToken(): String? { return authRepository.getIdToken() } fun testPanelDetection(context: Context) { viewModelScope.launch(mlDispatcher) { try { val modelFile = File(context.getExternalFilesDir(null), "best_float16.tflite") if (!modelFile.exists()) { withContext(Dispatchers.Main) { showBanner("Model not found", isError = true) } return@launch } val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ } ?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ } if (cbzItem == null) { withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) } return@launch } val uri = cbzItem.getUri() ?: return@launch Timber.d("BATCH TEST START: ${cbzItem.displayName}") var cacheFile: File? = null try { val detector = getOrInitDetector(context) ?: run { withContext(Dispatchers.Main) { showBanner("Model could not be loaded", isError = true) } return@launch } cacheFile = File(context.cacheDir, "temp_test_batch.cbz") context.contentResolver.openInputStream(uri)?.use { input -> cacheFile.outputStream().use { output -> input.copyTo(output) } } // 1. Initialize Model Once val initStartTime = System.currentTimeMillis() val initDuration = System.currentTimeMillis() - initStartTime Timber.d(">>> [BATCH] Model Initialization: ${initDuration}ms") val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) val totalPages = archiveDoc.getPageCount() val startIndex = 3 val numPagesToTest = 10 val endIndex = minOf(startIndex + numPagesToTest - 1, totalPages - 1) val resultsLog = StringBuilder() resultsLog.append("Batch Results:\n") // 2. Loop through pages for (i in startIndex..endIndex) { val page = archiveDoc.openPage(i) if (page != null) { val w = page.getPageWidthPoint() val h = page.getPageHeightPoint() if (w > 0 && h > 0) { val bitmap = androidx.core.graphics.createBitmap(w, h) page.renderPageBitmap(bitmap, 0, 0, w, h, false) // Measure precise inference time val pageStartTime = System.currentTimeMillis() val panels = detector.detectPanels(bitmap) val pageDuration = System.currentTimeMillis() - pageStartTime val logLine = "Page $i: ${pageDuration}ms (Found ${panels.size} panels)" Timber.d(">>>[BATCH] $logLine") resultsLog.append("$logLine\n") bitmap.recycle() delay(20) } page.close() } } archiveDoc.close() withContext(Dispatchers.Main) { Timber.i(resultsLog.toString()) showBanner("Batch test complete! Check logs for page-by-page timings.") } } finally { cacheFile?.delete() } } catch (e: Exception) { Timber.e(e, "Batch test failed") } } } suspend fun detectComicPanels(bitmap: Bitmap, context: Context): List { return withContext(mlDispatcher) { try { val detector = getOrInitDetector(context) detector?.detectPanels(bitmap) ?: emptyList() } catch (e: Exception) { Timber.e(e, "Error during panel detection") emptyList() } } } private suspend fun runSpeechBubbleDetection( bitmap: Bitmap, context: Context, confidenceThreshold: Float = 0.1f ): List { return withContext(mlDispatcher) { try { val detector = getOrInitSpeechBubbleDetector(context) if (detector == null) { Timber.tag("BubbleZoom").w("ViewModel: Detector is null!") return@withContext emptyList() } detector.detectBubbles(bitmap, confidenceThreshold) } catch (e: Exception) { Timber.tag("BubbleZoom").e(e, "ViewModel: Error during speech bubble detection") emptyList() } } } suspend fun detectSpeechBubbles(bitmap: Bitmap, context: Context): List { Timber.tag("BubbleZoom").d("ViewModel: detectSpeechBubbles called") val bubbles = runSpeechBubbleDetection(bitmap, context) Timber.tag("BubbleZoom").d("ViewModel: detectSpeechBubbles returning ${bubbles.size} bubbles") return bubbles } suspend fun detectSpeechBubblesCached( documentId: String, pageIndex: Int, bitmap: Bitmap, context: Context ): List { val key = SpeechBubbleCacheKey(documentId = documentId, pageIndex = pageIndex) val cachedBeforeLock = speechBubbleCache[key] if (cachedBeforeLock != null) { return scaleCachedSpeechBubbles(cachedBeforeLock, bitmap.width, bitmap.height) } val detectionJob: Deferred> speechBubbleCacheMutex.withLock { val cachedInsideLock = speechBubbleCache[key] if (cachedInsideLock != null) { detectionJob = CompletableDeferred(cachedInsideLock) } else { detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async { val detected = runSpeechBubbleDetection(bitmap, context) val normalized = normalizeSpeechBubbles(detected, bitmap.width, bitmap.height) speechBubbleCache[key] = normalized normalized }.also { job -> speechBubbleDetectionJobs[key] = job } } } val cached = try { detectionJob.await() } finally { speechBubbleCacheMutex.withLock { val activeJob = speechBubbleDetectionJobs[key] if (activeJob === detectionJob && detectionJob.isCompleted) { speechBubbleDetectionJobs.remove(key) } } } return scaleCachedSpeechBubbles(cached, bitmap.width, bitmap.height) } companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" internal const val KEY_SHELF_CONTENT_PREFIX = "shelf_content_" internal const val KEY_SHELF_TIMESTAMP_PREFIX = "shelf_timestamp_" internal const val KEY_SHELF_DELETED_PREFIX = "shelf_deleted_" private const val KEY_ADD_BOOKS_SOURCE = "add_books_source" private const val KEY_SYNC_ENABLED = "sync_enabled" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_INSTALLATION_ID = "installation_id" private const val KEY_APP_OPEN_COUNT = "app_open_count" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time" private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json" private const val MAX_FOLDER_LIMIT = 10 internal const val KEY_PINNED_HOME = "pinned_home_books" internal const val KEY_PINNED_LIBRARY = "pinned_library_books" private const val KEY_RECENT_FILES_LIMIT = "recent_files_limit" private const val KEY_TABS_ENABLED = "tabs_enabled" private const val KEY_OPEN_TAB_IDS = "open_tab_ids" private const val KEY_ACTIVE_TAB = "active_tab_book_id" private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id" private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled" private const val KEY_APP_THEME_MODE = "app_theme_mode" private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" private const val KEY_APP_SEED_COLOR = "app_seed_color" private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor" private const val KEY_APP_TEXT_DIM_FACTOR_LIGHT = "app_text_dim_factor_light" private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark" private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes" val SUPPORTED_MIME_TYPES = arrayOf( "application/pdf", "application/epub+zip", "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook", "text/markdown", "text/x-markdown", "text/plain", "text/html", "application/xhtml+xml", "application/x-fictionbook+xml", "application/x-zip-compressed-fb2", "application/zip", "application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed", "application/x-cb7", "application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml", "text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json", "application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin", "text/javascript", "application/javascript", "text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" ) } } private fun RecentFileItem.isManualOnlyReaderFile(): Boolean { return isManualOnlyReaderFileName(displayName) } private fun BookMetadata.isManualOnlyReaderFile(): Boolean { return isManualOnlyReaderFileName(displayName) }