Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ interface LibraryDao {
@Query("UPDATE library_items SET hasUpdates = 1 WHERE id = :id")
suspend fun markHasUpdates(id: String)

@Query("UPDATE library_items SET currentChapter = :currentChapter WHERE id = :id")
suspend fun updateCurrentChapter(id: String, currentChapter: String)

@Query("SELECT * FROM library_items WHERE isDownloaded = 1")
suspend fun getDownloadedItems(): List<LibraryItem>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ class LibraryRepository @Inject constructor(
true
} ?: false

/**
* Update only chapter-metadata columns via targeted UPDATEs, never touching progress
* columns. Used by the reader's chapter-list heal, which previously did a whole-row
* `updateItem` (REPLACE) outside `progressMutex` — a lost-update window that could revert a
* concurrent progress write. Targeted column writes make that read→write gap harmless, so
* no mutex is needed (same approach as the background-refresh path). Pass `null` to leave a
* field unchanged.
*/
suspend fun healChapterMetadata(
itemId: String,
totalChapters: Int?,
currentChapter: String?,
markHasUpdates: Boolean
): Boolean = runRepoCatching("Failed to heal chapter metadata", false) {
totalChapters?.let { libraryDao.updateTotalChapters(itemId, it) }
currentChapter?.let { libraryDao.updateCurrentChapter(itemId, it) }
if (markHasUpdates) libraryDao.markHasUpdates(itemId)
true
} ?: false

suspend fun updateReadingMode(itemId: String, readingMode: ReadingMode): Boolean =
runRepoCatching("Failed to update reading mode", false) {
libraryDao.getItemById(itemId)?.let { item ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,22 +431,28 @@ private suspend fun runScrollRestore(
return
}

val uiState = readerViewModel.uiState.value
val targetIndex = resolveRestoreIndex(uiState, stableKeys)
// isPagedMode is a UI-mode flag, not a stale-able progress field — always read it live.
val isPagedMode = readerViewModel.uiState.value.isPagedMode
// Anchor source: a genuine load/seek replays the frozen uiState anchor (unchanged
// behavior); a bare recomposition (e.g. returning from the full library screen) re-applies
// the live progressState position, so the user is never yanked back to a stale open-time
// anchor and no stale value is later persisted.
val anchor = readerViewModel.consumeRestoreAnchor()
val targetIndex = resolveRestoreIndex(anchor.elementKey, anchor.scrollIndex, stableKeys)
.coerceIn(0, content.paragraphs.lastIndex)
val targetFraction = uiState.restoreOffsetFraction
val targetFraction = anchor.offsetFraction
.takeIf { it >= 0f }
?.coerceIn(0f, 1f)

// One-shot jumps: paged mode uses the page index as the whole position; from-bottom
// navigation seeks the final item end. Anything else lands at the item and then chases
// the intra-item fraction via the watch-until-stable loop below.
val handledAsOneShot = when {
uiState.isPagedMode -> {
isPagedMode -> {
runCatching { pagerState.scrollToPage(targetIndex) }
true
}
uiState.targetScrollPosition == 100f -> {
anchor.targetScrollPosition == 100f -> {
runCatching { listState.scrollToItem(content.paragraphs.lastIndex, Int.MAX_VALUE) }
true
}
Expand All @@ -466,12 +472,12 @@ private suspend fun runScrollRestore(
// intra-item fraction — catches "landed at the wrong index but seek bar says 89%".
if (!readerViewModel.userHasDragged &&
shouldRunPercentRestoreFallback(
isPreciseRestore = uiState.isPreciseRestore,
isPreciseRestore = anchor.isPreciseRestore,
targetFraction = targetFraction
)
) {
val visiblePercent = computeVisiblePercent(listState, content.paragraphs.size)
val targetPercent = uiState.scrollPosition
val targetPercent = anchor.scrollPosition
if (visiblePercent != null && abs(visiblePercent - targetPercent) > RESTORE_PERCENT_TOLERANCE) {
val fallbackIndex = ((targetPercent / 100f) * content.paragraphs.lastIndex).toInt()
.coerceIn(0, content.paragraphs.lastIndex)
Expand Down Expand Up @@ -549,15 +555,15 @@ private suspend fun awaitStableRestore(
}

private fun resolveRestoreIndex(
uiState: ReaderViewModel.ReaderUiState,
elementKey: String,
fallbackIndex: Int,
stableKeys: List<String>
): Int {
val key = uiState.restoreElementKey
if (key.isNotEmpty()) {
val idx = stableKeys.indexOf(key)
if (elementKey.isNotEmpty()) {
val idx = stableKeys.indexOf(elementKey)
if (idx >= 0) return idx
}
return uiState.scrollIndex
return fallbackIndex
}

private fun computeVisiblePercent(listState: LazyListState, totalItems: Int): Float? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ class ReaderProgressController(
*/
var restoreInProgress: Boolean = false

/**
* Monotonic restore-request counter. Bumped ONLY at genuine restore chokepoints
* (`calculateInitialPosition` = a new chapter load, and `seekToProgress` via
* `requestRestore()`). A bare recomposition — e.g. returning from the full library screen,
* which disposes and recreates the reader composition — does NOT bump it. The UI restore
* path uses this to distinguish "replay the frozen load/seek anchor" from "re-apply the
* live scrolled position", which is what stops a library round-trip from clobbering progress.
*/
var restoreRequestId: Long = 0L
private set

fun requestRestore() {
restoreRequestId++
}

private var lastRawScrollOffset: Float = -1f
private var lastReportedIndex: Int = -1
private var lastReportedFractionMillis: Int = -1
Expand Down Expand Up @@ -135,6 +150,7 @@ class ReaderProgressController(
fromBottom: Boolean,
isExplicitNavigation: Boolean
): ReaderProgressState {
requestRestore()
suppressAutoNavUntilUserInteraction = true
hasUserInteractedSinceLoad = false
userHasDragged = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,53 @@ class ReaderViewModel @Inject constructor(
progressController.beginRestore()
}

// Survives recomposition because ReaderViewModel is activity-scoped. Tracks the last
// restore-request id the UI restore path already consumed, so a bare recomposition — the
// reader being disposed and recreated on a library round-trip, with no new load/seek —
// is told to re-apply the live scrolled position instead of replaying the frozen load/seek
// anchor from uiState, which is what silently clobbered progress.
private var lastConsumedRestoreId: Long = 0L

/**
* Called once per `runScrollRestore` entry. Decides whether this restore is a GENUINE
* load/seek (replay the frozen `uiState` anchor — unchanged behavior) or a BARE
* RECOMPOSITION (re-apply the live `progressState` position), marks the request consumed,
* and returns the anchor to apply. The decision lives here — not in the composable — so it
* is unit-testable.
*/
fun consumeRestoreAnchor(): RestoreAnchor {
val pendingId = progressController.restoreRequestId
val isGenuine = pendingId != lastConsumedRestoreId
lastConsumedRestoreId = pendingId
return if (isGenuine) {
val ui = _uiState.value
RestoreAnchor(
elementKey = ui.restoreElementKey,
scrollIndex = ui.scrollIndex,
offsetFraction = ui.restoreOffsetFraction,
scrollPosition = ui.scrollPosition,
isPreciseRestore = ui.isPreciseRestore,
targetScrollPosition = ui.targetScrollPosition,
isLiveSource = false
)
} else {
val live = progressController.progressState.value
RestoreAnchor(
elementKey = live.scrollElementKey,
scrollIndex = live.scrollIndex,
offsetFraction = live.scrollOffsetFraction,
scrollPosition = live.scrollPosition,
isPreciseRestore = live.isPreciseRestore,
// Hard-null: never replay a from-bottom / seek-to-end one-shot on a bare return.
// That one-shot is only meaningful for a genuine load/seek (which always take the
// genuine branch above). The live scrollIndex/fraction/percent already encode a
// real end-of-chapter landing if that is where the user actually is.
targetScrollPosition = null,
isLiveSource = true
)
}
}

// Resolved intrinsic dimensions keyed by image URL, one Compose State per URL. A
// ReaderImageView subscribes to its own url's State, so a write only recomposes that one
// image — and an item scrolled away and back is sized correctly on its FIRST composition
Expand Down Expand Up @@ -237,6 +284,21 @@ class ReaderViewModel @Inject constructor(
val toastMessage: String?
)

/**
* The anchor `runScrollRestore` applies. `isLiveSource` distinguishes the genuine load/seek
* path (frozen `uiState` fields) from the bare-recomposition path (live `progressState`).
* See [consumeRestoreAnchor].
*/
data class RestoreAnchor(
val elementKey: String,
val scrollIndex: Int,
val offsetFraction: Float,
val scrollPosition: Float,
val isPreciseRestore: Boolean,
val targetScrollPosition: Float?,
val isLiveSource: Boolean
)

data class ReaderUiState(
val content: ChapterContent? = null,
val isLoading: Boolean = true,
Expand Down Expand Up @@ -1225,6 +1287,9 @@ class ReaderViewModel @Inject constructor(
// the restore loop triggered by seekTrigger does not later suppress saves, and
// pass forcePersist=true to bypass the upstream-layout-stability gate (which
// would otherwise reject seeks into chapters with unmeasured images).
// requestRestore() makes the seekTrigger-driven runScrollRestore read the fresh
// uiState seek anchor (genuine branch) rather than the live progressState.
progressController.requestRestore()
progressController.markUserDragged()

viewModelScope.launch {
Expand Down Expand Up @@ -1398,12 +1463,14 @@ class ReaderViewModel @Inject constructor(
markerChapterNumber != null &&
markerChapterNumber >= item.totalChapters.toDouble() &&
item.hasFinishedProgress()
libraryRepository.updateItem(
item.copy(
totalChapters = if (countChanged) newCount else item.totalChapters,
currentChapter = healedChapter ?: item.currentChapter,
hasUpdates = if (wasCaughtUp) true else item.hasUpdates
)
// Targeted metadata write (not a whole-row REPLACE) so a concurrent progress write
// between the getItemById above and here is never clobbered. healedChapter is
// already null unless the label changed; markHasUpdates only ever sets the flag.
libraryRepository.healChapterMetadata(
itemId = id,
totalChapters = if (countChanged) newCount else null,
currentChapter = healedChapter,
markHasUpdates = wasCaughtUp
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,46 @@ class LibraryRepositoryUpdateTest {
clearInvocations(libraryDao)
}

@Test
fun `healChapterMetadata writes only targeted meta columns and never a whole-row replace`() = runBlocking {
val itemId = "heal-id"

val result = repository.healChapterMetadata(
itemId = itemId,
totalChapters = 42,
currentChapter = "Chapter 42",
markHasUpdates = true
)

assertTrue(result)
verify(libraryDao).updateTotalChapters(itemId, 42)
verify(libraryDao).updateCurrentChapter(itemId, "Chapter 42")
verify(libraryDao).markHasUpdates(itemId)
// The whole point of the fix: no whole-row REPLACE, so a concurrent progress write can
// never be clobbered. insertItem is the REPLACE funnel used by updateItem/progress writes.
verify(libraryDao, never()).insertItem(any())
verify(libraryDao, never()).getItemById(any())
Unit
}

@Test
fun `healChapterMetadata skips null fields and the unset updates flag`() = runBlocking {
val itemId = "heal-id"

repository.healChapterMetadata(
itemId = itemId,
totalChapters = null,
currentChapter = "Chapter 7",
markHasUpdates = false
)

verify(libraryDao, never()).updateTotalChapters(any(), any())
verify(libraryDao).updateCurrentChapter(itemId, "Chapter 7")
verify(libraryDao, never()).markHasUpdates(any())
verify(libraryDao, never()).insertItem(any())
Unit
}

@Test
fun testUpdateNovelInfo_CallsDaoUpdate() = runBlocking {
val itemId = "test-id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ class ReaderProgressControllerTest {
)
}

@Test
fun `calculateInitialPosition increments restoreRequestId`() = runTest {
val controller = ReaderProgressController(libraryRepository, this)
val content = ChapterContent(
paragraphs = List(10) { ContentElement.Text("Text $it") },
title = "Chapter 1",
url = "http://example.com/1"
)
val libraryItem = LibraryItem(
id = "test-id",
url = "http://example.com/1",
title = "Novel",
progress = 40,
lastScrollPosition = 40f
)

assertEquals(0L, controller.restoreRequestId)
controller.calculateInitialPosition(content, libraryItem, fromBottom = false, isExplicitNavigation = false)
assertEquals(1L, controller.restoreRequestId)
// The explicit-nav path (which ignores the saved item) still counts as a genuine request.
controller.calculateInitialPosition(content, libraryItem, fromBottom = false, isExplicitNavigation = true)
assertEquals(2L, controller.restoreRequestId)
}

@Test
fun `calculateInitialPosition restores via element key when present`() = runTest {
val controller = ReaderProgressController(libraryRepository, this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ class ReaderViewModelNavigationTest {
whenever(contentRepository.incrementChapterUrl(currentUrl)).thenReturn("http://example.com/guessed-next")
whenever(contentRepository.decrementChapterUrl(currentUrl)).thenReturn("http://example.com/ch1")
whenever(libraryRepository.getItemById(currentItem.id)).thenReturn(currentItem)
whenever(libraryRepository.updateItem(any())).thenReturn(true)
whenever(libraryRepository.healChapterMetadata(any(), anyOrNull(), anyOrNull(), any())).thenReturn(true)
whenever(exploreRepository.getNovelDetails(baseUrl, sourceName)).thenReturn(details)

viewModel.loadContent(currentUrl, currentItem.id)
Expand All @@ -307,9 +307,13 @@ class ReaderViewModelNavigationTest {
listOf("http://example.com/ch1", currentUrl, "http://example.com/ch3"),
viewModel.uiState.value.fullChapterList.map { it.url }
)
verify(libraryRepository).updateItem(check<LibraryItem> {
assertEquals(currentItem.id, it.id)
assertEquals(3, it.totalChapters)
})
// Heal writes only metadata via targeted UPDATEs (never a whole-row replace), so it can't
// clobber a concurrent progress write. totalChapters goes 1 -> 3; no label heal here.
verify(libraryRepository).healChapterMetadata(
eq(currentItem.id),
eq(3),
anyOrNull(),
eq(false)
)
}
}
Loading
Loading