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).
This commit is contained in:
Aryan 2026-03-13 22:43:17 +05:30 committed by GitHub
parent 843a77d0ef
commit 74e2cec415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 235 additions and 119 deletions

View file

@ -69,7 +69,9 @@ class MainActivity : ComponentActivity() {
} }
} }
if (savedInstanceState == null) {
handleIntent(intent) handleIntent(intent)
}
lifecycleScope.launch { lifecycleScope.launch {
platformFeaturesRepository.checkForUpdates(this@MainActivity, updateLauncher) platformFeaturesRepository.checkForUpdates(this@MainActivity, updateLauncher)

View file

@ -1235,10 +1235,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
val bookToSync = uiState.value.recentFiles.find { val uriString = _internalState.value.selectedPdfUri?.toString()
it.uriString == (uiState.value.selectedPdfUri?.toString() ?: _internalState.value.selectedEpubUri?.toString()
?: uiState.value.selectedEpubUri?.toString())
}
_internalState.update { _internalState.update {
it.copy( it.copy(
@ -1254,9 +1252,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
bookToSync?.let { if (uriString != null) {
viewModelScope.launch {
val freshBook = recentFilesRepository.getFileByUri(uriString)
freshBook?.let {
if (uiState.value.uploadingBookIds.contains(it.bookId)) { if (uiState.value.uploadingBookIds.contains(it.bookId)) {
return return@launch
} }
if (uiState.value.isSyncEnabled) { if (uiState.value.isSyncEnabled) {
Timber.d("Book closed, triggering metadata sync for ${it.bookId}") Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
@ -1266,13 +1267,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (it.sourceFolderUri != null) { if (it.sourceFolderUri != null) {
Timber.tag("FolderAnnotationSync") Timber.tag("FolderAnnotationSync")
.d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}") .d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}")
viewModelScope.launch {
recentFilesRepository.syncLocalMetadataToFolder(it.bookId) recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId) recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId)
} }
} }
} }
} }
}
private fun registerOrUpdateDeviceOnSignIn(userId: String) { private fun registerOrUpdateDeviceOnSignIn(userId: String) {
viewModelScope.launch { viewModelScope.launch {
@ -2965,7 +2966,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else { } else {
0f 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 { viewModelScope.launch {
recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
recentFilesRepository.updatePdfReadingPosition( 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.")
} }
} }

View file

@ -19,7 +19,7 @@
*/ */
// EpubReaderScreen.kt // EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead", @file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused" "UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
) )
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
@ -409,7 +409,7 @@ fun EpubReaderHost(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = 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 isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
@ -817,17 +817,22 @@ fun EpubReaderHost(
} }
} }
LaunchedEffect(initialLocator, initialCfi) { val configuration = androidx.compose.ui.platform.LocalConfiguration.current
if (currentRenderMode == RenderMode.VERTICAL_SCROLL && initialLocator != null && cfiToLoad == null) { var lastOrientation by remember { mutableIntStateOf(configuration.orientation) }
if (!initialCfi.isNullOrBlank()) {
Timber.d("V_SCROLL: Using raw initialCfi: $initialCfi") LaunchedEffect(configuration.orientation) {
if (currentChapterIndex != initialLocator.chapterIndex) { if (lastOrientation != configuration.orientation) {
currentChapterIndex = initialLocator.chapterIndex 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 { else {
if (targetChunk != null && targetChunk >= 0) { if (targetChunk != null && targetChunk >= 0) {
isNavigatingToBookmark = true isNavigatingToPosition = true
if (targetChunk >= loadedChunkCount) { if (targetChunk >= loadedChunkCount) {
Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk") Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk")
@ -1556,9 +1561,6 @@ fun EpubReaderHost(
loadUpToChunkIndex = targetChunk loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1) loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
} else { } 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) val content = chapterChunks.getOrNull(targetChunk)
if (content != null) { if (content != null) {
val escaped = escapeJsString(content) val escaped = escapeJsString(content)
@ -1576,8 +1578,8 @@ fun EpubReaderHost(
scope.launch { scope.launch {
delay(3000) delay(3000)
if (isNavigatingToBookmark) { if (isNavigatingToPosition) {
isNavigatingToBookmark = false isNavigatingToPosition = false
} }
} }
} else { } else {
@ -1591,6 +1593,7 @@ fun EpubReaderHost(
} }
RenderMode.PAGINATED -> { RenderMode.PAGINATED -> {
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
isNavigatingToPosition = true
val locator = locatorConverter.getLocatorFromCfi( val locator = locatorConverter.getLocatorFromCfi(
book = epubBook, book = epubBook,
chapterIndex = bookmark.chapterIndex, chapterIndex = bookmark.chapterIndex,
@ -1610,11 +1613,13 @@ fun EpubReaderHost(
paginatedPagerState.scrollToPage(chapterStartPage) paginatedPagerState.scrollToPage(chapterStartPage)
} }
} }
isNavigatingToPosition = false
} else { } else {
Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.") Timber.w("P-Mode Click: Failed to convert CFI to Locator. Using old findPageForCfi as a fallback.")
paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex -> paginator?.findPageForCfi(bookmark.chapterIndex, bookmark.cfi) { pageIndex ->
scope.launch { scope.launch {
paginatedPagerState.scrollToPage(pageIndex) paginatedPagerState.scrollToPage(pageIndex)
isNavigatingToPosition = false
} }
} }
} }
@ -1635,11 +1640,48 @@ fun EpubReaderHost(
val targetChunk = locator?.let { it.blockIndex / 20 } val targetChunk = locator?.let { it.blockIndex / 20 }
if (highlight.chapterIndex != currentChapterIndex) { if (highlight.chapterIndex != currentChapterIndex) {
chunkTargetOverride = targetChunk ?: 0 chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0
currentChapterIndex = highlight.chapterIndex currentChapterIndex = highlight.chapterIndex
} else { } else {
if (targetChunk != null && targetChunk >= loadedChunkCount) { 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 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 { } else {
webViewRefForTts?.evaluateJavascript( webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');", "javascript:window.scrollToCfi('${escapeJsString(highlight.cfi)}');",
@ -1649,10 +1691,26 @@ fun EpubReaderHost(
} }
} }
RenderMode.PAGINATED -> { RenderMode.PAGINATED -> {
isNavigatingToPosition = true
val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi) val locator = locatorConverter.getLocatorFromCfi(epubBook, highlight.chapterIndex, highlight.cfi)
if (locator != null) { if (locator != null) {
val pageIndex = (paginator as? BookPaginator)?.findPageForLocator(locator) 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 -> onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success") Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToBookmark = false isNavigatingToPosition = false
}, },
ttsScope = scope, ttsScope = scope,
onTtsTextReady = { jsonString -> onTtsTextReady = { jsonString ->
@ -3483,7 +3541,7 @@ fun EpubReaderHost(
} }
) )
if (isNavigatingToBookmark) { if (isNavigatingToPosition) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -3495,7 +3553,7 @@ fun EpubReaderHost(
CircularProgressIndicator() CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Navigating to bookmark...", text = "Navigating to position...",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground
) )

View file

@ -530,6 +530,20 @@ fun PaginatedReaderScreen(
var debouncedTextAlign by remember { mutableStateOf(textAlign) } var debouncedTextAlign by remember { mutableStateOf(textAlign) }
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) } var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(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( val textStyle = remember(
baseTextStyle, baseTextStyle,
@ -556,8 +570,6 @@ fun PaginatedReaderScreen(
) )
} }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, fontFamily, textAlign) { LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.") Timber.d("Formatting changed. Waiting for debounce.")
@ -2902,7 +2914,7 @@ private fun PaginatedTextSelectionMenu(
onHighlight: ((HighlightColor) -> Unit)?, onHighlight: ((HighlightColor) -> Unit)?,
onDelete: (() -> Unit)?, onDelete: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean, @Suppress("unused") isProUser: Boolean,
isOss: Boolean, @Suppress("unused") isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(), activeHighlightPalette: List<HighlightColor> = emptyList(),
onOpenPaletteManager: (() -> Unit)? = null onOpenPaletteManager: (() -> Unit)? = null
) { ) {

View file

@ -419,6 +419,7 @@ internal fun PdfVerticalReader(
state.snapToPageHandler = { index -> state.snapToPageHandler = { index ->
val clampedPanY = calculateTargetPanY(index) val clampedPanY = calculateTargetPanY(index)
Timber.tag("PdfPositionDebug").d("VerticalReader: snapToPage($index) called. ClampedPanY: $clampedPanY")
if (clampedPanY != null) { if (clampedPanY != null) {
panYAnimatable.snapTo(clampedPanY) panYAnimatable.snapTo(clampedPanY)
} }
@ -1210,9 +1211,7 @@ internal fun PdfVerticalReader(
LaunchedEffect(visiblePages, screenHeight) { LaunchedEffect(visiblePages, screenHeight) {
snapshotFlow { snapshotFlow {
Pair( Pair(panYAnimatable.value, zoomAnimatable.value)
panYAnimatable.value, zoomAnimatable.value
)
}.collectLatest { (panY, zoom) -> }.collectLatest { (panY, zoom) ->
if (visiblePages.isNotEmpty()) { if (visiblePages.isNotEmpty()) {
state.firstVisiblePage = visiblePages.first().index state.firstVisiblePage = visiblePages.first().index
@ -1228,6 +1227,7 @@ internal fun PdfVerticalReader(
} }
if (mostVisible != null && mostVisible.index != state.currentPage) { if (mostVisible != null && mostVisible.index != state.currentPage) {
Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)")
state.currentPage = mostVisible.index state.currentPage = mostVisible.index
} }
} }

View file

@ -29,11 +29,6 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager 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.Bitmap
import android.graphics.RectF import android.graphics.RectF
import android.net.Uri import android.net.Uri
@ -167,6 +162,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
@ -228,7 +224,10 @@ import androidx.core.graphics.createBitmap
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
@ -283,6 +282,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -708,9 +709,10 @@ fun PdfViewerScreen(
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) } var isPdfDarkMode by remember { mutableStateOf(loadPdfDarkMode(context)) }
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) } var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by remember { mutableStateOf(true) } var showBars by rememberSaveable { mutableStateOf(true) }
var isFullScreen by remember { mutableStateOf(false) } var isFullScreen by remember { mutableStateOf(false) }
var documentPassword by remember { mutableStateOf<String?>(null) } var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
var isScrollLocked by remember { mutableStateOf(false) } var isScrollLocked by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) }
var isPasswordError by remember { mutableStateOf(false) } var isPasswordError by remember { mutableStateOf(false) }
@ -761,6 +763,7 @@ fun PdfViewerScreen(
} }
var isDockDragging by remember { mutableStateOf(false) } var isDockDragging by remember { mutableStateOf(false) }
var initialScrollDone by remember { mutableStateOf(false) }
var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) }
@ -810,11 +813,9 @@ fun PdfViewerScreen(
var showZoomIndicator by remember { mutableStateOf(false) } var showZoomIndicator by remember { mutableStateOf(false) }
var bookmarks by remember(pdfUri) { mutableStateOf(loadPdfBookmarksFromJson(initialBookmarksJson)) } var bookmarks by remember(pdfUri) { mutableStateOf(loadPdfBookmarksFromJson(initialBookmarksJson)) }
var showPenPlayground by remember { mutableStateOf(false) } var showPenPlayground by rememberSaveable { mutableStateOf(false) }
var isEditMode by rememberSaveable { mutableStateOf(false) }
var isEditMode by remember { mutableStateOf(false) } var isDockMinimized by rememberSaveable { mutableStateOf(false) }
var isDockMinimized by remember { mutableStateOf(false) }
val isDrawingActive by remember(isEditMode, isDockMinimized) { val isDrawingActive by remember(isEditMode, isDockMinimized) {
derivedStateOf { isEditMode && !isDockMinimized } derivedStateOf { isEditMode && !isDockMinimized }
@ -988,9 +989,7 @@ fun PdfViewerScreen(
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState() val toolSettings by annotationSettingsRepo.settings.collectAsState()
var showToolSettings by rememberSaveable { mutableStateOf(false) }
var showToolSettings by remember { mutableStateOf(false) }
val isHighlighterSnapEnabled = toolSettings.isHighlighterSnapEnabled val isHighlighterSnapEnabled = toolSettings.isHighlighterSnapEnabled
val selectedTool = toolSettings.getActiveTool() val selectedTool = toolSettings.getActiveTool()
@ -1051,7 +1050,7 @@ fun PdfViewerScreen(
var totalPages by remember { mutableIntStateOf(0) } var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) } var currentPageScale by remember { mutableFloatStateOf(1f) }
val textBoxes = remember { mutableStateListOf<PdfTextBox>() } val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) } var selectedTextBoxId by rememberSaveable { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() } val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val drawingState = remember { PdfDrawingState() } val drawingState = remember { PdfDrawingState() }
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) } 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) } 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 currentAnnotations by rememberUpdatedState(allAnnotations)
val currentTextBoxes by rememberUpdatedState(textBoxes.toList()) val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
@ -1084,24 +1091,39 @@ fun PdfViewerScreen(
val currentBookmarks by rememberUpdatedState(bookmarks) val currentBookmarks by rememberUpdatedState(bookmarks)
val currentTotalPages by rememberUpdatedState(totalDisplayPages) val currentTotalPages by rememberUpdatedState(totalDisplayPages)
val currentPageState by rememberUpdatedState(currentPage) val currentPageState by rememberUpdatedState(currentPage)
val currentPendingPage by rememberUpdatedState(pendingRestorePage)
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) { val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
{ force: Boolean -> { force: Boolean ->
coroutineScope.launch { viewModel.viewModelScope.launch {
val bookId = currentBookId ?: return@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 annots = currentAnnotations
val boxes = currentTextBoxes val boxes = currentTextBoxes
val highlights = currentHighlights val highlights = currentHighlights
val bms = currentBookmarks val bms = currentBookmarks
val page = currentPageState
val totalPgs = currentTotalPages 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 annotsHash = annots.hashCode()
val boxesHash = boxes.hashCode() val boxesHash = boxes.hashCode()
val highlightsHash = highlights.hashCode() val highlightsHash = highlights.hashCode()
val bmsHash = bms.hashCode() val bmsHash = bms.hashCode()
// Protect the lock and I/O execution with NonCancellable
withContext(NonCancellable) { withContext(NonCancellable) {
saveMutex.withLock { saveMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -1131,13 +1153,13 @@ fun PdfViewerScreen(
} }
} }
val bookmarksJson = JSONArray(objectList).toString() val bookmarksJson = JSONArray(objectList).toString()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { onBookmarksChanged(bookmarksJson) }
onBookmarksChanged(bookmarksJson)
}
lastSavedHashes[3] = bmsHash lastSavedHashes[3] = bmsHash
didSave = true didSave = true
} }
if (force || page != lastSavedHashes[4]) { if (force || page != lastSavedHashes[4]) {
Timber.tag("PdfPositionDebug").d("UI: COMMIT SAVE | Page: $page | Total: $totalPgs | Force: $force")
if (totalPgs > 0) { if (totalPgs > 0) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onSavePosition(page, totalPgs) onSavePosition(page, totalPgs)
@ -1145,10 +1167,6 @@ fun PdfViewerScreen(
} }
lastSavedHashes[4] = page lastSavedHashes[4] = page
} }
if (didSave) {
Timber.tag("PdfSavePerf").d("Saved data for book $bookId")
}
} }
} }
} }
@ -1159,13 +1177,18 @@ fun PdfViewerScreen(
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.") val shouldSave = initialScrollDone || (currentPageState != 0)
coroutineScope.launch {
if (shouldSave) {
viewModel.viewModelScope.launch {
if (richTextController != null) { if (richTextController != null) {
withContext(NonCancellable) { richTextController.saveImmediate() } withContext(NonCancellable) { richTextController.saveImmediate() }
} }
saveAllData(true).join() saveAllData(true).join()
} }
} else {
Timber.tag("PdfPositionDebug").w("Lifecycle $event triggered: skipping save (initial settling).")
}
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
@ -1735,35 +1758,44 @@ fun PdfViewerScreen(
} }
} }
LaunchedEffect(isDocumentReady) { LaunchedEffect(isDocumentReady, totalDisplayPages, displayMode) {
if (isDocumentReady && !initialScrollDone) { if (isDocumentReady && !initialScrollDone) {
val pageCount = pagerState.pageCount val pageCount = totalDisplayPages
if (pageCount > 0) { if (pageCount <= 0) return@LaunchedEffect
val targetPage = initialPage?.coerceIn(0, pageCount - 1) ?: 0
Timber.d("Initial Setup: Document is ready. Target page: $targetPage.") val targetPage = pendingRestorePage?.coerceIn(0, pageCount - 1) ?: 0
Timber.tag("PdfPositionDebug").i("UI: Restoration Start | Target: $targetPage | Mode: $displayMode | Total: $pageCount")
try {
delay(200)
coroutineScope.launch {
if (pagerState.currentPage != targetPage) {
when (displayMode) { when (displayMode) {
DisplayMode.PAGINATION -> { DisplayMode.PAGINATION -> {
Timber.d("Initial Setup: Animating scroll to page $targetPage.") if (pagerState.currentPage != targetPage) {
pagerState.scrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} }
DisplayMode.VERTICAL_SCROLL -> {
Timber.d("Initial Setup: Snapping scroll to item $targetPage.")
verticalReaderState.snapToPage(targetPage)
pagerState.scrollToPage(targetPage)
}
}
}
initialScrollDone = true initialScrollDone = true
Timber.d("Initial Setup: Scroll complete. Page saving is now enabled.")
} }
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
}
}
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 { } else {
Timber.w( Timber.tag("PdfPositionDebug").e(e, "UI: Restoration error.")
"Initial Setup: Document is ready, but pager pageCount is still 0. This may happen on rapid open/close. Scroll will be skipped." initialScrollDone = true
) }
} }
} }
} }
@ -2032,21 +2064,24 @@ fun PdfViewerScreen(
} else { } else {
ttsController.stop() ttsController.stop()
coroutineScope.launch { viewModel.viewModelScope.launch {
initialScrollDone = true
if (richTextController != null) { if (richTextController != null) {
withContext(NonCancellable) { withContext(NonCancellable) {
Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...")
richTextController.saveImmediate() richTextController.saveImmediate()
} }
} }
saveAllData(true).join() saveAllData(true).join()
Timber.tag("AnnotationSync").d("Save complete. Navigating back.") withContext(Dispatchers.Main) {
Timber.tag("PdfPositionDebug").d("Exit save complete. Navigating back.")
onNavigateBack() onNavigateBack()
} }
} }
} }
}
val onZoomChangeStable = remember { { scale: Float -> currentPageScale = scale } } val onZoomChangeStable = remember { { scale: Float -> currentPageScale = scale } }
@ -5006,16 +5041,22 @@ fun PdfViewerScreen(
} }
saveAllData(true).join() saveAllData(true).join()
val resolvedPage = if (!initialScrollDone && currentPage == 0) {
pendingRestorePage ?: 0
} else {
currentPage
}
if (hasReflowFile) { if (hasReflowFile) {
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId } val item = uiState.allRecentFiles.find { it.bookId == reflowBookId }
if (item != null) { if (item != null) {
viewModel.switchToFileSeamlessly(item, currentPage) viewModel.switchToFileSeamlessly(item, resolvedPage)
} else { } else {
viewModel.generateAndImportReflowFile( viewModel.generateAndImportReflowFile(
pdfBookId = bookId, pdfBookId = bookId,
pdfUri = pdfUri, pdfUri = pdfUri,
originalTitle = originalFileName, originalTitle = originalFileName,
autoOpenPage = currentPage autoOpenPage = resolvedPage
) )
} }
} else { } else {
@ -5023,7 +5064,7 @@ fun PdfViewerScreen(
pdfBookId = bookId, pdfBookId = bookId,
pdfUri = pdfUri, pdfUri = pdfUri,
originalTitle = originalFileName, originalTitle = originalFileName,
autoOpenPage = currentPage autoOpenPage = resolvedPage
) )
} }
} }