From 74e2cec415db45b5c9088dbebb42a32b0e56c88e Mon Sep 17 00:00:00 2001 From: Aryan Date: Fri, 13 Mar 2026 22:43:17 +0530 Subject: [PATCH] Position persistence during orientation change (#64) * Fixed PDF reading position restoration during orientation change * Refactored navigation logic in `EpubReaderScreen` and `PaginatedReader` to improve position restoration and UI consistency. - Renamed `isNavigatingToBookmark` to `isNavigatingToPosition` and updated the overlay UI to reflect general position navigation. - Added an orientation change listener to restore the reading position in `VERTICAL_SCROLL` mode using the last known locator. - Improved highlight navigation in `VERTICAL_SCROLL` and `PAGINATED` modes, including chunk injection logic for highlights and fallback pagination logic. - Implemented an anchor locator mechanism in `PaginatedReader` to preserve the reading position during layout constraint changes (e.g., orientation or resizing). --- .../java/com/aryan/reader/MainActivity.kt | 4 +- .../java/com/aryan/reader/MainViewModel.kt | 45 ++--- .../reader/epubreader/EpubReaderScreen.kt | 108 ++++++++--- .../reader/paginatedreader/PaginatedReader.kt | 18 +- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 6 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 173 +++++++++++------- 6 files changed, 235 insertions(+), 119 deletions(-) diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index db66671..cb69b53 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -69,7 +69,9 @@ class MainActivity : ComponentActivity() { } } - handleIntent(intent) + if (savedInstanceState == null) { + handleIntent(intent) + } lifecycleScope.launch { platformFeaturesRepository.checkForUpdates(this@MainActivity, updateLauncher) diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 9dfd813..6f23d46 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -1235,10 +1235,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - val bookToSync = uiState.value.recentFiles.find { - it.uriString == (uiState.value.selectedPdfUri?.toString() - ?: uiState.value.selectedEpubUri?.toString()) - } + val uriString = _internalState.value.selectedPdfUri?.toString() + ?: _internalState.value.selectedEpubUri?.toString() _internalState.update { it.copy( @@ -1254,21 +1252,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - bookToSync?.let { - if (uiState.value.uploadingBookIds.contains(it.bookId)) { - return - } - if (uiState.value.isSyncEnabled) { - Timber.d("Book closed, triggering metadata sync for ${it.bookId}") - uploadSingleBookMetadata(it) - } + 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}") - viewModelScope.launch { - recentFilesRepository.syncLocalMetadataToFolder(it.bookId) - recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) + 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) + } } } } @@ -1383,8 +1384,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() val syncRequest = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints( - constraints - ).build() + constraints + ).build() workManager.enqueueUniquePeriodicWork( FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest ) @@ -2965,7 +2966,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { 0f } - Timber.d("Saving PDF position locally: URI=$currentPdfUri, Page=$page") + Timber.tag("PdfPositionDebug").d("ViewModel: Saving to DB | Page: $page | Total: $totalPages | URI: ${currentPdfUri.lastPathSegment}") viewModelScope.launch { recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.updatePdfReadingPosition( @@ -2973,6 +2974,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } } + } else { + Timber.tag("PdfPositionDebug").w("ViewModel: Save aborted. No selectedPdfUri found in state.") } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 599723f..c9c3e64 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -19,7 +19,7 @@ */ // EpubReaderScreen.kt @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead", - "UnusedVariable", "Unused" + "UnusedVariable", "Unused", "SimplifyBooleanWithConstants" ) package com.aryan.reader.epubreader @@ -409,7 +409,7 @@ fun EpubReaderHost( val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() } - var isNavigatingToBookmark by remember { mutableStateOf(false) } + var isNavigatingToPosition by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } @@ -817,17 +817,22 @@ fun EpubReaderHost( } } - LaunchedEffect(initialLocator, initialCfi) { - if (currentRenderMode == RenderMode.VERTICAL_SCROLL && initialLocator != null && cfiToLoad == null) { - if (!initialCfi.isNullOrBlank()) { - Timber.d("V_SCROLL: Using raw initialCfi: $initialCfi") - if (currentChapterIndex != initialLocator.chapterIndex) { - currentChapterIndex = initialLocator.chapterIndex + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + var lastOrientation by remember { mutableIntStateOf(configuration.orientation) } + + LaunchedEffect(configuration.orientation) { + if (lastOrientation != configuration.orientation) { + lastOrientation = configuration.orientation + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + lastKnownLocator?.let { locator -> + scope.launch { + val cfi = locatorConverter.getCfiFromLocator(epubBook, locator) + if (cfi != null) { + delay(300L) + webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(cfi)}');", null) + } + } } - cfiToLoad = initialCfi - } else { - Timber.w("V_SCROLL: initialCfi is null or blank. Position cannot be restored from locator as conversion is disabled per request.") - isInitialCfiLoad = false } } } @@ -1537,7 +1542,7 @@ fun EpubReaderHost( } else { if (targetChunk != null && targetChunk >= 0) { - isNavigatingToBookmark = true + isNavigatingToPosition = true if (targetChunk >= loadedChunkCount) { Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk") @@ -1556,9 +1561,6 @@ fun EpubReaderHost( loadUpToChunkIndex = targetChunk loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } else { - // Even if loadedChunkCount is high enough in Kotlin state, - // ensure the specific chunk for the bookmark is actually in the DOM. - // (Sometimes rapid jumps might leave gaps if logic was loose) val content = chapterChunks.getOrNull(targetChunk) if (content != null) { val escaped = escapeJsString(content) @@ -1576,8 +1578,8 @@ fun EpubReaderHost( scope.launch { delay(3000) - if (isNavigatingToBookmark) { - isNavigatingToBookmark = false + if (isNavigatingToPosition) { + isNavigatingToPosition = false } } } else { @@ -1591,6 +1593,7 @@ fun EpubReaderHost( } RenderMode.PAGINATED -> { Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") + isNavigatingToPosition = true val locator = locatorConverter.getLocatorFromCfi( book = epubBook, chapterIndex = bookmark.chapterIndex, @@ -1610,11 +1613,13 @@ fun EpubReaderHost( paginatedPagerState.scrollToPage(chapterStartPage) } } + isNavigatingToPosition = false } else { Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.") paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex -> scope.launch { paginatedPagerState.scrollToPage(pageIndex) + isNavigatingToPosition = false } } } @@ -1635,11 +1640,48 @@ fun EpubReaderHost( val targetChunk = locator?.let { it.blockIndex / 20 } if (highlight.chapterIndex != currentChapterIndex) { - chunkTargetOverride = targetChunk ?: 0 + chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0 currentChapterIndex = highlight.chapterIndex } else { - if (targetChunk != null && targetChunk >= loadedChunkCount) { - loadUpToChunkIndex = targetChunk + if (targetChunk != null && targetChunk >= 0) { + isNavigatingToPosition = true + + if (targetChunk >= loadedChunkCount) { + val chunksToInject = (loadedChunkCount..targetChunk) + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } + loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) + } else { + val content = chapterChunks.getOrNull(targetChunk) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($targetChunk, '$escaped');", + null + ) + } + } + + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');", + null + ) + + scope.launch { + delay(3000) + if (isNavigatingToPosition) { + isNavigatingToPosition = false + } + } } else { webViewRefForTts?.evaluateJavascript( "javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');", @@ -1649,10 +1691,26 @@ fun EpubReaderHost( } } RenderMode.PAGINATED -> { + isNavigatingToPosition = true val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) if (locator != null) { val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator) - if (pageIndex != null) paginatedPagerState.scrollToPage(pageIndex) + if (pageIndex != null) { + paginatedPagerState.scrollToPage(pageIndex) + } else { + val chapterStartPage = (paginator as? BookPaginator)?.chapterStartPageIndices?.get(highlight.chapterIndex) + if (chapterStartPage != null) { + paginatedPagerState.scrollToPage(chapterStartPage) + } + } + isNavigatingToPosition = false + } else { + paginator?.findPageForCfi(highlight.chapterIndex, highlight.cfi) { pageIndex -> + scope.launch { + paginatedPagerState.scrollToPage(pageIndex) + isNavigatingToPosition = false + } + } } } } @@ -2126,7 +2184,7 @@ fun EpubReaderHost( }, onScrollFinished = { success -> Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success") - isNavigatingToBookmark = false + isNavigatingToPosition = false }, ttsScope = scope, onTtsTextReady = { jsonString -> @@ -3483,7 +3541,7 @@ fun EpubReaderHost( } ) - if (isNavigatingToBookmark) { + if (isNavigatingToPosition) { Box( modifier = Modifier .fillMaxSize() @@ -3495,7 +3553,7 @@ fun EpubReaderHost( CircularProgressIndicator() Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Navigating to bookmark...", + text = "Navigating to position...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground ) diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index cee987d..c6d15da 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -530,6 +530,20 @@ fun PaginatedReaderScreen( var debouncedTextAlign by remember { mutableStateOf(textAlign) } var anchorLocatorForReconfig by remember { mutableStateOf(null) } + val currentPaginatorRef = remember { mutableStateOf(null) } + + val previousConstraints = remember { arrayOf(this.constraints) } + if (previousConstraints[0] != this.constraints) { + val activePaginator = currentPaginatorRef.value + if (activePaginator is BookPaginator) { + val currentPage = pagerState.currentPage + val locator = activePaginator.getLocatorForPage(currentPage) + if (locator != null) { + anchorLocatorForReconfig = locator + } + } + previousConstraints[0] = this.constraints + } val textStyle = remember( baseTextStyle, @@ -556,8 +570,6 @@ fun PaginatedReaderScreen( ) } - val currentPaginatorRef = remember { mutableStateOf(null) } - LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, fontFamily, textAlign) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) { Timber.d("Formatting changed. Waiting for debounce.") @@ -2902,7 +2914,7 @@ private fun PaginatedTextSelectionMenu( onHighlight: ((HighlightColor) -> Unit)?, onDelete: (() -> Unit)?, @Suppress("unused") isProUser: Boolean, - isOss: Boolean, + @Suppress("unused") isOss: Boolean, activeHighlightPalette: List = emptyList(), onOpenPaletteManager: (() -> Unit)? = null ) { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index e583556..7ce1f82 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -419,6 +419,7 @@ internal fun PdfVerticalReader( state.snapToPageHandler = { index -> val clampedPanY = calculateTargetPanY(index) + Timber.tag("PdfPositionDebug").d("VerticalReader: snapToPage($index) called. ClampedPanY: $clampedPanY") if (clampedPanY != null) { panYAnimatable.snapTo(clampedPanY) } @@ -1210,9 +1211,7 @@ internal fun PdfVerticalReader( LaunchedEffect(visiblePages, screenHeight) { snapshotFlow { - Pair( - panYAnimatable.value, zoomAnimatable.value - ) + Pair(panYAnimatable.value, zoomAnimatable.value) }.collectLatest { (panY, zoom) -> if (visiblePages.isNotEmpty()) { state.firstVisiblePage = visiblePages.first().index @@ -1228,6 +1227,7 @@ internal fun PdfVerticalReader( } if (mostVisible != null && mostVisible.index != state.currentPage) { + Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)") state.currentPage = mostVisible.index } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index c085929..dbf40f1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -29,11 +29,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.pm.PackageManager -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.ui.platform.LocalLifecycleOwner -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import android.graphics.Bitmap import android.graphics.RectF import android.net.Uri @@ -167,6 +162,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment @@ -228,7 +224,10 @@ import androidx.core.graphics.createBitmap import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems @@ -283,6 +282,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject @@ -708,9 +709,10 @@ fun PdfViewerScreen( var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) } var pageAspectRatios by remember { mutableStateOf>(emptyList()) } - var showBars by remember { mutableStateOf(true) } + var showBars by rememberSaveable { mutableStateOf(true) } var isFullScreen by remember { mutableStateOf(false) } - var documentPassword by remember { mutableStateOf(null) } + var documentPassword by rememberSaveable { mutableStateOf(null) } + var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) } var isScrollLocked by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) } @@ -761,6 +763,7 @@ fun PdfViewerScreen( } var isDockDragging by remember { mutableStateOf(false) } + var initialScrollDone by remember { mutableStateOf(false) } var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) } @@ -810,11 +813,9 @@ fun PdfViewerScreen( var showZoomIndicator by remember { mutableStateOf(false) } var bookmarks by remember(pdfUri) { mutableStateOf(loadPdfBookmarksFromJson(initialBookmarksJson)) } - var showPenPlayground by remember { mutableStateOf(false) } - - var isEditMode by remember { mutableStateOf(false) } - - var isDockMinimized by remember { mutableStateOf(false) } + var showPenPlayground by rememberSaveable { mutableStateOf(false) } + var isEditMode by rememberSaveable { mutableStateOf(false) } + var isDockMinimized by rememberSaveable { mutableStateOf(false) } val isDrawingActive by remember(isEditMode, isDockMinimized) { derivedStateOf { isEditMode && !isDockMinimized } @@ -988,9 +989,7 @@ fun PdfViewerScreen( val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val toolSettings by annotationSettingsRepo.settings.collectAsState() - - var showToolSettings by remember { mutableStateOf(false) } - + var showToolSettings by rememberSaveable { mutableStateOf(false) } val isHighlighterSnapEnabled = toolSettings.isHighlighterSnapEnabled val selectedTool = toolSettings.getActiveTool() @@ -1051,7 +1050,7 @@ fun PdfViewerScreen( var totalPages by remember { mutableIntStateOf(0) } var currentPageScale by remember { mutableFloatStateOf(1f) } val textBoxes = remember { mutableStateListOf() } - var selectedTextBoxId by remember { mutableStateOf(null) } + var selectedTextBoxId by rememberSaveable { mutableStateOf(null) } val userHighlights = remember { mutableStateListOf() } val drawingState = remember { PdfDrawingState() } val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) } @@ -1070,13 +1069,21 @@ fun PdfViewerScreen( } } - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - val saveMutex = remember { Mutex() } - - var initialScrollDone by remember { mutableStateOf(false) } var isDocumentReady by remember { mutableStateOf(false) } - val lastSavedHashes = remember(currentBookId) { IntArray(5) { 0 } } + LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) { + if (isDocumentReady && totalPages > 0) { + if (initialScrollDone) { + Timber.tag("PdfPositionDebug").v("UI: Tracking | currentPage: $currentPage | pendingRestorePage updated") + pendingRestorePage = currentPage + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + val saveMutex = remember { Mutex() } + + val lastSavedHashes = remember(currentBookId) { IntArray(5) { -1 } } val currentAnnotations by rememberUpdatedState(allAnnotations) val currentTextBoxes by rememberUpdatedState(textBoxes.toList()) @@ -1084,24 +1091,39 @@ fun PdfViewerScreen( val currentBookmarks by rememberUpdatedState(bookmarks) val currentTotalPages by rememberUpdatedState(totalDisplayPages) val currentPageState by rememberUpdatedState(currentPage) + val currentPendingPage by rememberUpdatedState(pendingRestorePage) val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) { { force: Boolean -> - coroutineScope.launch { + viewModel.viewModelScope.launch { val bookId = currentBookId ?: return@launch + + if (!isDocumentReady && !force) { + Timber.tag("PdfPositionDebug").w("UI: Save ignored. Document not ready.") + return@launch + } + val annots = currentAnnotations val boxes = currentTextBoxes val highlights = currentHighlights val bms = currentBookmarks - val page = currentPageState val totalPgs = currentTotalPages + val restoreTarget = currentPendingPage ?: 0 + val page = if (!initialScrollDone) { + Timber.tag("PdfPositionDebug").i("UI: Save during restoration | Using restoreTarget: $restoreTarget (CurrentUI: $currentPageState)") + restoreTarget + } else { + currentPageState + } + + Timber.tag("PdfPositionDebug").v("UI: Save logic | Choosing: $page (UI: $currentPageState, Target: $restoreTarget, Done: $initialScrollDone)") + val annotsHash = annots.hashCode() val boxesHash = boxes.hashCode() val highlightsHash = highlights.hashCode() val bmsHash = bms.hashCode() - // Protect the lock and I/O execution with NonCancellable withContext(NonCancellable) { saveMutex.withLock { withContext(Dispatchers.IO) { @@ -1131,13 +1153,13 @@ fun PdfViewerScreen( } } val bookmarksJson = JSONArray(objectList).toString() - withContext(Dispatchers.Main) { - onBookmarksChanged(bookmarksJson) - } + withContext(Dispatchers.Main) { onBookmarksChanged(bookmarksJson) } lastSavedHashes[3] = bmsHash didSave = true } + if (force || page != lastSavedHashes[4]) { + Timber.tag("PdfPositionDebug").d("UI: COMMIT SAVE | Page: $page | Total: $totalPgs | Force: $force") if (totalPgs > 0) { withContext(Dispatchers.Main) { onSavePosition(page, totalPgs) @@ -1145,10 +1167,6 @@ fun PdfViewerScreen( } lastSavedHashes[4] = page } - - if (didSave) { - Timber.tag("PdfSavePerf").d("Saved data for book $bookId") - } } } } @@ -1159,12 +1177,17 @@ fun PdfViewerScreen( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { - Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.") - coroutineScope.launch { - if (richTextController != null) { - withContext(NonCancellable) { richTextController.saveImmediate() } + val shouldSave = initialScrollDone || (currentPageState != 0) + + if (shouldSave) { + viewModel.viewModelScope.launch { + if (richTextController != null) { + withContext(NonCancellable) { richTextController.saveImmediate() } + } + saveAllData(true).join() } - saveAllData(true).join() + } else { + Timber.tag("PdfPositionDebug").w("Lifecycle $event triggered: skipping save (initial settling).") } } } @@ -1735,35 +1758,44 @@ fun PdfViewerScreen( } } - LaunchedEffect(isDocumentReady) { + LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode) { if (isDocumentReady && !initialScrollDone) { - val pageCount = pagerState.pageCount - if (pageCount > 0) { - val targetPage = initialPage?.coerceIn(0, pageCount - 1) ?: 0 - Timber.d("Initial Setup: Document is ready. Target page: $targetPage.") + val pageCount = totalDisplayPages + if (pageCount <= 0) return@LaunchedEffect - coroutineScope.launch { - if (pagerState.currentPage != targetPage) { - when (displayMode) { - DisplayMode.PAGINATION -> { - Timber.d("Initial Setup: Animating scroll to page $targetPage.") - pagerState.scrollToPage(targetPage) - } + val targetPage = pendingRestorePage?.coerceIn(0, pageCount - 1) ?: 0 + Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount") - DisplayMode.VERTICAL_SCROLL -> { - Timber.d("Initial Setup: Snapping scroll to item $targetPage.") - verticalReaderState.snapToPage(targetPage) - pagerState.scrollToPage(targetPage) - } + try { + delay(200) + + when (displayMode) { + DisplayMode.PAGINATION -> { + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) } + initialScrollDone = true + } + DisplayMode.VERTICAL_SCROLL -> { + var retries = 0 + while (verticalReaderState.snapToPageHandler == null && retries < 20) { + delay(50) + retries++ + } + Timber.tag("PdfPositionDebug").d("UI: Executing Vertical snapToPage($targetPage) after $retries retries") + verticalReaderState.snapToPage(targetPage) + delay(100) + initialScrollDone = true } - initialScrollDone = true - Timber.d("Initial Setup: Scroll complete. Page saving is now enabled.") } - } else { - Timber.w( - "Initial Setup: Document is ready, but pager pageCount is still 0. This may happen on rapid open/close. Scroll will be skipped." - ) + Timber.tag("PdfPositionDebug").i("UI: Restoration Complete | Now at Page: $currentPage") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) { + Timber.tag("PdfPositionDebug").w("UI: Restoration cancelled (likely new recomposition)") + } else { + Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.") + initialScrollDone = true + } } } } @@ -2032,18 +2064,21 @@ fun PdfViewerScreen( } else { ttsController.stop() - coroutineScope.launch { + viewModel.viewModelScope.launch { + initialScrollDone = true + if (richTextController != null) { withContext(NonCancellable) { - Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...") richTextController.saveImmediate() } } saveAllData(true).join() - Timber.tag("AnnotationSync").d("Save complete. Navigating back.") - onNavigateBack() + withContext(Dispatchers.Main) { + Timber.tag("PdfPositionDebug").d("Exit save complete. Navigating back.") + onNavigateBack() + } } } } @@ -5006,16 +5041,22 @@ fun PdfViewerScreen( } saveAllData(true).join() + val resolvedPage = if (!initialScrollDone && currentPage == 0) { + pendingRestorePage ?: 0 + } else { + currentPage + } + if (hasReflowFile) { val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } if (item != null) { - viewModel.switchToFileSeamlessly(item, currentPage) + viewModel.switchToFileSeamlessly(item, resolvedPage) } else { viewModel.generateAndImportReflowFile( pdfBookId = bookId, pdfUri = pdfUri, originalTitle = originalFileName, - autoOpenPage = currentPage + autoOpenPage = resolvedPage ) } } else { @@ -5023,7 +5064,7 @@ fun PdfViewerScreen( pdfBookId = bookId, pdfUri = pdfUri, originalTitle = originalFileName, - autoOpenPage = currentPage + autoOpenPage = resolvedPage ) } }