From 5bf4169a6db9696a3140b08b2eba1e3011bd97c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:54:02 +0000 Subject: [PATCH 1/9] Fix(reader): skip dimension-persist cascade when values are unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncImage.onSuccess re-fires every time a recycled item re-enters composition (memory-cache hits included), so a fast up/down drag calls persistImageDimensions once per re-entry per image. Each call wrote a fresh Pair into the SnapshotStateMap (invalidating every composed reader of the map), re-enqueued a Room upsert of the same row, and cancelled/ relaunched the content-dim apply job — a per-frame storm during the exact gesture where scroll jank is most visible. Early-return when the stored dimensions already match, so revisited images no longer pay any of that. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../ui/viewmodel/ImageDimensionManager.kt | 13 +++- .../ui/viewmodel/ImageDimensionManagerTest.kt | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index e50a2bc8..9d8f85f4 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -41,9 +41,16 @@ class ImageDimensionManager( fun persistImageDimensions(imageUrl: String, width: Int, height: Int) { if (imageUrl.isBlank() || width <= 0 || height <= 0) return - resolvedImageDimensions[imageUrl] = width to height - pendingImageDimensions[imageUrl] = width to height - contentDimUpdates[imageUrl] = width to height + val dims = width to height + // A recycled item re-entering composition re-fires AsyncImage.onSuccess with the same + // dimensions (memory-cache hits included), so during fast up/down scrolling this is + // called once per re-entry per image. Skip the whole cascade — snapshot write (which + // invalidates every composed reader of the map), Room rewrite, and apply-job churn — + // when nothing actually changed. + if (resolvedImageDimensions[imageUrl] == dims) return + resolvedImageDimensions[imageUrl] = dims + pendingImageDimensions[imageUrl] = dims + contentDimUpdates[imageUrl] = dims scheduleDimensionDbFlush() scheduleContentDimApply() } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt new file mode 100644 index 00000000..6fc08273 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt @@ -0,0 +1,76 @@ +package io.aatricks.easyreader.ui.viewmodel + +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ImageDimensionManagerTest { + + private class RecordingApplier { + val batches = mutableListOf>>() + fun apply(updates: Map>) { + batches.add(updates) + } + } + + @Test + fun `persist stores dimensions and applies content rebuild once`() = runTest { + val applier = RecordingApplier() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(800 to 1200, manager.resolvedImageDimensions["http://img/1.jpg"]) + assertEquals(listOf(mapOf("http://img/1.jpg" to (800 to 1200))), applier.batches) + } + + @Test + fun `duplicate persist of identical dimensions is a no-op`() = runTest { + val applier = RecordingApplier() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + // Re-entry of a recycled item re-reports the same dimensions. + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(1, applier.batches.size) + } + + @Test + fun `changed dimensions for the same url go through`() = runTest { + val applier = RecordingApplier() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + manager.persistImageDimensions("http://img/1.jpg", 900, 1600) + advanceUntilIdle() + + assertEquals(900 to 1600, manager.resolvedImageDimensions["http://img/1.jpg"]) + assertEquals(2, applier.batches.size) + assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), applier.batches[1]) + } + + @Test + fun `blank url and non-positive dimensions are rejected`() = runTest { + val applier = RecordingApplier() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + + manager.persistImageDimensions("", 800, 1200) + manager.persistImageDimensions("http://img/1.jpg", 0, 1200) + manager.persistImageDimensions("http://img/1.jpg", 800, -1) + advanceUntilIdle() + + assertNull(manager.resolvedImageDimensions["http://img/1.jpg"]) + assertEquals(0, applier.batches.size) + } +} From e4acfb65a3bfa08ee04518352c7a7b83634bfb04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:54:02 +0000 Subject: [PATCH 2/9] Perf(reader): per-URL dimension state instead of one SnapshotStateMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SnapshotStateMap tracks reads per-map, not per-key, so every visible image (each reads the map in ReaderImageView and in the ScrollingReaderView item scope for slice count) was invalidated and recomposed whenever ANY image resolved its dimensions — despite the comment claiming per-key granularity. During a scroll through a fresh chapter that meant every decode recomposed the whole visible set. Replace the map with one MutableState per URL behind ConcurrentHashMap.computeIfAbsent (atomic, unlike getOrPut, so the State a composable subscribes to is always the one later writes land on). Read sites subscribe via remember(url) + State delegate, so a write now invalidates only that image's scope. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../ui/components/ReaderImageView.kt | 5 ++- .../screens/reader/ReaderContentRenderers.kt | 8 +++- .../ui/viewmodel/ImageDimensionManager.kt | 42 +++++++++++++------ .../ui/viewmodel/ReaderViewModel.kt | 14 ++++--- .../ui/viewmodel/ImageDimensionManagerTest.kt | 21 ++++++++-- 5 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt index 43e52f9b..defe1302 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt @@ -118,10 +118,11 @@ fun ReaderImageView( dynamicHeight = dynamicHeight ) var runtimeDimensions by remember(imageUrl, pageUrl) { mutableStateOf(null) } - // Shared, fine-grained resolved dimensions (survives this item being recycled). Lets a + // Shared, per-URL resolved dimensions (survive this item being recycled). Lets a // re-entering item size itself on first composition instead of collapsing to the loading // placeholder and re-laying-out — the cause of stutter when dragging up/down quickly. - val sharedDimensions = readerViewModel.resolvedImageDimensions[imageUrl] + // Reading through a per-URL State means a write for another image cannot invalidate this one. + val sharedDimensions by remember(imageUrl) { readerViewModel.imageDimensionState(imageUrl) } val effectiveDimensions = effectiveImageDimensions( declaredWidth = width, declaredHeight = height, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt index 23a028e1..f7b3755f 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -442,7 +443,12 @@ internal fun ScrollingReaderView( } is ContentElement.Image -> { - val resolved = readerViewModel.resolvedImageDimensions[element.url] + // Per-URL State: another image resolving its dimensions must not invalidate + // this item's scope (a map-wide read here caused every visible image to + // recompose on each decode during scroll). + val resolved by remember(element.url) { + readerViewModel.imageDimensionState(element.url) + } val imgW = resolved?.first ?: element.width val imgH = resolved?.second ?: element.height val screenWidthPx = with(LocalDensity.current) { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index 9d8f85f4..3b693426 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -1,6 +1,8 @@ package io.aatricks.easyreader.ui.viewmodel -import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -8,6 +10,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import android.util.Log import io.aatricks.easyreader.util.rethrowCancellation +import java.util.concurrent.ConcurrentHashMap /** * Owns the resolved-image-dimension pipeline extracted from ReaderViewModel. @@ -21,12 +24,14 @@ class ImageDimensionManager( private val imageDimensionCache: ImageDimensionCacheRepository, private val applyContentDimensions: (Map>) -> Unit, ) { - // Resolved intrinsic dimensions keyed by image URL, as fine-grained Compose state. A - // ReaderImageView reads its own entry, so a write only recomposes that one image — and an - // item scrolled away and back is sized correctly on its FIRST composition (no collapse to the - // loading placeholder + relayout). This is what keeps fast up/down dragging smooth; the - // debounced content rebuild stays only for persistence / restore math. - val resolvedImageDimensions = mutableStateMapOf>() + // Resolved intrinsic dimensions keyed by image URL, one MutableState per URL. A single + // SnapshotStateMap would NOT give per-image granularity — its read tracking is per-map, so + // any write invalidates every composed reader. With one State per URL, a first-decode write + // recomposes only that image — and an item scrolled away and back is sized correctly on its + // FIRST composition (no collapse to the loading placeholder + relayout), which is what keeps + // fast up/down dragging smooth. The debounced content rebuild stays only for persistence / + // restore math. + private val dimensionStates = ConcurrentHashMap?>>() private val pendingImageDimensions = LinkedHashMap>() private val contentDimUpdates = LinkedHashMap>() @@ -39,16 +44,27 @@ class ImageDimensionManager( private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L } + /** + * Composable-friendly per-URL dimension state: a write invalidates only readers of THIS url. + * computeIfAbsent (not getOrPut, which is non-atomic on ConcurrentHashMap) guarantees a + * single State instance per url, so subscription and later writes always meet. + */ + fun dimensionState(imageUrl: String): State?> = + dimensionStates.computeIfAbsent(imageUrl) { mutableStateOf(null) } + + /** Non-observing read for tests and non-compose callers. */ + fun resolvedDimensions(imageUrl: String): Pair? = dimensionStates[imageUrl]?.value + fun persistImageDimensions(imageUrl: String, width: Int, height: Int) { if (imageUrl.isBlank() || width <= 0 || height <= 0) return val dims = width to height // A recycled item re-entering composition re-fires AsyncImage.onSuccess with the same // dimensions (memory-cache hits included), so during fast up/down scrolling this is - // called once per re-entry per image. Skip the whole cascade — snapshot write (which - // invalidates every composed reader of the map), Room rewrite, and apply-job churn — - // when nothing actually changed. - if (resolvedImageDimensions[imageUrl] == dims) return - resolvedImageDimensions[imageUrl] = dims + // called once per re-entry per image. Skip the whole cascade — snapshot write, Room + // rewrite, and apply-job churn — when nothing actually changed. + val state = dimensionStates.computeIfAbsent(imageUrl) { mutableStateOf(null) } + if (state.value == dims) return + state.value = dims pendingImageDimensions[imageUrl] = dims contentDimUpdates[imageUrl] = dims scheduleDimensionDbFlush() @@ -103,6 +119,6 @@ class ImageDimensionManager( fun reset() { contentDimApplyJob?.cancel() contentDimUpdates.clear() - resolvedImageDimensions.clear() + dimensionStates.clear() } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index b943162e..01f1bde7 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -73,18 +73,20 @@ class ReaderViewModel @Inject constructor( progressController.beginRestore() } - // Resolved intrinsic dimensions keyed by image URL, as fine-grained Compose state. A - // ReaderImageView reads its own entry, so a write only recomposes that one image — and an - // item scrolled away and back is sized correctly on its FIRST composition (no collapse to the - // loading placeholder + relayout). This is what keeps fast up/down dragging smooth; the - // debounced `content` rebuild below stays only for persistence / restore math. + // 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 + // (no collapse to the loading placeholder + relayout). This is what keeps fast up/down + // dragging smooth; the debounced `content` rebuild below stays only for persistence / + // restore math. private val imageDimensionManager = ImageDimensionManager( scope = viewModelScope, imageDimensionCache = imageDimensionCache, applyContentDimensions = ::updateCurrentContentImageDimensions, ) - val resolvedImageDimensions get() = imageDimensionManager.resolvedImageDimensions + fun imageDimensionState(imageUrl: String): androidx.compose.runtime.State?> = + imageDimensionManager.dimensionState(imageUrl) fun persistImageDimensions(imageUrl: String, width: Int, height: Int) = imageDimensionManager.persistImageDimensions(imageUrl, width, height) diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt index 6fc08273..d53c1c62 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -26,10 +27,24 @@ class ImageDimensionManagerTest { manager.persistImageDimensions("http://img/1.jpg", 800, 1200) advanceUntilIdle() - assertEquals(800 to 1200, manager.resolvedImageDimensions["http://img/1.jpg"]) + assertEquals(800 to 1200, manager.resolvedDimensions("http://img/1.jpg")) assertEquals(listOf(mapOf("http://img/1.jpg" to (800 to 1200))), applier.batches) } + @Test + fun `dimensionState returns the same observable instance and tracks persists`() = runTest { + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) {} + + val state = manager.dimensionState("http://img/1.jpg") + assertSame(state, manager.dimensionState("http://img/1.jpg")) + assertNull(state.value) + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(800 to 1200, state.value) + } + @Test fun `duplicate persist of identical dimensions is a no-op`() = runTest { val applier = RecordingApplier() @@ -55,7 +70,7 @@ class ImageDimensionManagerTest { manager.persistImageDimensions("http://img/1.jpg", 900, 1600) advanceUntilIdle() - assertEquals(900 to 1600, manager.resolvedImageDimensions["http://img/1.jpg"]) + assertEquals(900 to 1600, manager.resolvedDimensions("http://img/1.jpg")) assertEquals(2, applier.batches.size) assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), applier.batches[1]) } @@ -70,7 +85,7 @@ class ImageDimensionManagerTest { manager.persistImageDimensions("http://img/1.jpg", 800, -1) advanceUntilIdle() - assertNull(manager.resolvedImageDimensions["http://img/1.jpg"]) + assertNull(manager.resolvedDimensions("http://img/1.jpg")) assertEquals(0, applier.batches.size) } } From 3d7472825f19240e339b27db4f5f3b6308767ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:54:02 +0000 Subject: [PATCH 3/9] Fix(reader): prune image-dimension state on chapter change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dimension state was never cleared: ImageDimensionManager.reset() was only reachable from ReaderViewModel.resetState(), which has no callers, and the ViewModel is Activity-scoped — so the per-URL state grew by every image ever displayed, for the whole session. Replace reset() with pruneForChapter(currentImageUrls), called after a chapter load succeeds (web + epub paths). Only entries missing from the new chapter are dropped: a same-chapter reload keeps its dims, a failed navigation never prunes (the old chapter stays on screen), and revisited chapters re-seed declared dimensions from the Room cache at parse time. Pending DB flushes are left untouched so in-flight persists complete. Delete the dead resetState(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../ui/viewmodel/ImageDimensionManager.kt | 18 +++++++---- .../ui/viewmodel/ReaderViewModel.kt | 28 +++++++++++------ .../ui/viewmodel/ImageDimensionManagerTest.kt | 29 ++++++++++++++++++ .../ui/viewmodel/ReaderViewModelTest.kt | 30 +++++++++++++++++++ 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index 3b693426..b2b5c7ae 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -113,12 +113,18 @@ class ImageDimensionManager( } } - // Matches the reader's resetState: drop the queued in-memory rebuild and the resolved map. - // The db-flush job / pending map are intentionally left running so an in-flight persist of - // already-resolved dimensions still completes. - fun reset() { + /** + * Called when a new chapter's content replaces the old one. Drops per-URL state for images + * that are not part of the new chapter — without this the manager grows unboundedly for the + * life of the (Activity-scoped) ReaderViewModel, one entry per image ever displayed. Keeping + * the new chapter's own urls means a same-chapter reload keeps its dims. The db-flush job / + * pending map are intentionally left running so an in-flight persist of already-resolved + * dimensions still completes (the on-disk cache is cross-chapter by design). + */ + fun pruneForChapter(currentImageUrls: Set) { + dimensionStates.keys.retainAll(currentImageUrls) contentDimApplyJob?.cancel() - contentDimUpdates.clear() - dimensionStates.clear() + contentDimUpdates.keys.retainAll(currentImageUrls) + if (contentDimUpdates.isNotEmpty()) scheduleContentDimApply() } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index 01f1bde7..68341e54 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -108,6 +108,20 @@ class ReaderViewModel @Inject constructor( } } + private fun collectImageUrls(elements: List): Set { + val urls = mutableSetOf() + fun visit(element: ContentElement) { + when (element) { + is ContentElement.Image -> urls.add(element.url) + is ContentElement.ImageGroup -> element.images.forEach { urls.add(it.url) } + is ContentElement.PageContent -> element.elements.forEach(::visit) + else -> Unit + } + } + elements.forEach(::visit) + return urls + } + private fun ContentElement.withResolvedImageDimensions(updates: Map>): ContentElement { return when (this) { is ContentElement.Image -> { @@ -568,6 +582,10 @@ class ReaderViewModel @Inject constructor( preCalculatedTextCount = result.textCount, preCalculatedImageCount = result.imageCount ) + // Only after a successful load — a failed navigation keeps the old chapter (and its + // dimension state) on screen. Parse-time seeding from the Room cache restores anything + // pruned here if the user navigates back. + imageDimensionManager.pruneForChapter(collectImageUrls(content.paragraphs)) val libraryItem = effectiveId?.let { libraryRepository.getItemById(it) } val baseTitle = getBaseTitle(content, libraryItem) @@ -859,6 +877,7 @@ class ReaderViewModel @Inject constructor( previousChapterUrl = chapter.previousHref?.let { "$epubPath#${it}" } ?: epubBook.getPreviousHref(href)?.let { "$epubPath#${it}" } ) + imageDimensionManager.pruneForChapter(collectImageUrls(content.paragraphs)) val tocChapterList = epubBook.getFlatToc() .map { ChapterInfo(title = it.title, url = "$epubPath#${it.href}") } @@ -1092,15 +1111,6 @@ class ReaderViewModel @Inject constructor( } } - fun resetState(): Unit { - closeContent(_uiState.value.content) - _uiState.value = ReaderUiState() - progressController.resetState() - isExplicitNavigation = false - lastRawScrollOffset = -1f - imageDimensionManager.reset() - } - private fun closeContent(content: ChapterContent?) { (content?.paragraphs as? java.io.Closeable)?.close() } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt index d53c1c62..7399b528 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt @@ -75,6 +75,35 @@ class ImageDimensionManagerTest { assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), applier.batches[1]) } + @Test + fun `pruneForChapter drops stale urls and keeps current ones`() = runTest { + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) {} + + manager.persistImageDimensions("http://old/1.jpg", 800, 1200) + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + advanceUntilIdle() + + manager.pruneForChapter(setOf("http://kept/2.jpg", "http://new/3.jpg")) + + assertNull(manager.resolvedDimensions("http://old/1.jpg")) + assertEquals(900 to 1400, manager.resolvedDimensions("http://kept/2.jpg")) + } + + @Test + fun `pruneForChapter reschedules content apply only for surviving urls`() = runTest { + val applier = RecordingApplier() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + + manager.persistImageDimensions("http://old/1.jpg", 800, 1200) + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + // Prune before the 350ms apply debounce fires: the old chapter's update must not + // survive into the rebuilt batch, the kept one must. + manager.pruneForChapter(setOf("http://kept/2.jpg")) + advanceUntilIdle() + + assertEquals(listOf(mapOf("http://kept/2.jpg" to (900 to 1400))), applier.batches) + } + @Test fun `blank url and non-positive dimensions are rejected`() = runTest { val applier = RecordingApplier() diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt index 3c47481b..883cf773 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt @@ -764,6 +764,36 @@ class ReaderViewModelTest { assertEquals(1920, image.height) } + @Test + fun `navigating to a new chapter prunes previous chapter dimension state`() = runTest { + val itemId = "item-1" + val urlA = "https://example.com/webtoon/chapter-10" + val urlB = "https://example.com/webtoon/chapter-11" + val imageA = "https://cdn.example.com/panel-a.jpg" + val imageB = "https://cdn.example.com/panel-b.jpg" + + whenever(contentRepository.loadContent(urlA)).thenReturn( + ContentResult.Success(elements = listOf(ContentElement.Image(imageA)), title = "Chapter 10", url = urlA) + ) + whenever(contentRepository.loadContent(urlB)).thenReturn( + ContentResult.Success(elements = listOf(ContentElement.Image(imageB)), title = "Chapter 11", url = urlB) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Chapter 10", url = urlA) + ) + + viewModel.loadContent(urlA, itemId) + advanceUntilIdle() + viewModel.persistImageDimensions(imageA, 1080, 1920) + advanceUntilIdle() + assertEquals(1080 to 1920, viewModel.imageDimensionState(imageA).value) + + viewModel.loadContent(urlB, itemId) + advanceUntilIdle() + + assertNull(viewModel.imageDimensionState(imageA).value) + } + @Test fun `scroll progress saves when current item is stable even before upstream image resolves`() = runTest { val itemId = "item-1" From f7e5d82d8cbd9aeba34f226689582d0b328992af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:54:03 +0000 Subject: [PATCH 4/9] Perf(reader): memoize media-state probe, single probe per image entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLikelyMediaState stats up to 3 candidate files (exists/length/mtime) and ReaderImageView called it twice per image item — on the main thread, in composition, re-executed every time a recycled item re-entered during a scroll gesture. Memoize the state string in ImageCache and invalidate it wherever a url's candidate files can change: the cache mutators, trimToSize/clearAll (whole-memo drop, trim deletes arbitrary files), and every terminal branch of WebContentLoader.downloadAndCacheImageInternal — the only production path that creates or replaces cached media. Staleness can never render a wrong image: HttpMediaCacheFetcher re-checks disk authoritatively; the string only feeds the Coil memory-cache key, the loading-UI choice, and repair eligibility. ReaderImageView now derives isInitiallyCached from localMediaState, so each composition entry performs one (memoized) probe instead of two disk probes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../data/repository/content/ImageCache.kt | 30 ++++++++++- .../repository/content/WebContentLoader.kt | 4 ++ .../ui/components/ReaderImageView.kt | 26 ++++----- .../data/repository/content/ImageCacheTest.kt | 53 +++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt index 07c5551a..94688324 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt @@ -23,18 +23,38 @@ class ImageCache @Inject constructor( fun getLikelyCachedMediaFile(url: String): File? = candidateFiles(url).firstOrNull { it.exists() && it.length() > 0L } + // Memoized: the reader probes this on the main thread for every image item entering + // composition (twice per item before the memo), and a fast up/down scroll re-enters items + // continuously — up to 3 candidate files × exists/length/mtime syscalls each time. Entries + // are dropped whenever a write path touches the url's candidate files (see mutators below + // and WebContentLoader.downloadAndCacheImageInternal); staleness is otherwise harmless + // because HttpMediaCacheFetcher re-checks disk authoritatively before serving. fun getLikelyMediaState(url: String): String { + if (mediaStateMemo.size > MAX_MEDIA_STATE_MEMO) mediaStateMemo.clear() + return mediaStateMemo.computeIfAbsent(url) { computeLikelyMediaState(it) } + } + + private fun computeLikelyMediaState(url: String): String { val file = getLikelyCachedMediaFile(url) ?: return "missing" return "${file.absolutePath}:${file.length()}:${file.lastModified()}" } + fun invalidateMediaState(url: String) { + mediaStateMemo.remove(url) + } + fun getRootDir(): File = mediaCacheDir fun getCacheSize(): Long = FileSizeUtils.calculateDirectorySize(mediaCacheDir) fun getDownloadsSize(): Long = FileSizeUtils.calculateDirectorySize(mediaDownloadsDir) - fun trimToSize(maxBytes: Long): Long = FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) + fun trimToSize(maxBytes: Long): Long { + // Trim deletes arbitrary cache files; there is no per-url mapping back, so drop the + // whole media-state memo. + mediaStateMemo.clear() + return FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) + } fun findExistingCachedMediaFile(url: String): File? = candidateFiles(url).firstOrNull { it.exists() && it.isCachedImageValid() } @@ -59,6 +79,8 @@ class ImageCache @Inject constructor( private data class IntegrityKey(val path: String, val length: Long, val mtime: Long) private val integrityVerdicts = ConcurrentHashMap() + private val mediaStateMemo = ConcurrentHashMap() + private fun File.isCachedImageValid(): Boolean { if (!exists() || length() <= 0L) return false val key = IntegrityKey(absolutePath, length(), lastModified()) @@ -71,27 +93,33 @@ class ImageCache @Inject constructor( private companion object { private const val MAX_INTEGRITY_VERDICTS = 4096 + private const val MAX_MEDIA_STATE_MEMO = 4096 } fun deleteCachedMediaFiles(url: String) { + mediaStateMemo.remove(url) candidateFiles(url).forEach { it.delete() } } fun deleteDownloadedMediaFile(url: String) { + mediaStateMemo.remove(url) File(mediaDownloadsDir, CacheKeyUtils.keyFor(url)).delete() } fun clearAll() { + mediaStateMemo.clear() mediaCacheDir.deleteRecursively() mediaCacheDir.mkdirs() } fun clearAllDownloads() { + mediaStateMemo.clear() mediaDownloadsDir.deleteRecursively() mediaDownloadsDir.mkdirs() } fun promoteToDownloads(url: String): File? { + mediaStateMemo.remove(url) val key = CacheKeyUtils.keyFor(url) val target = File(mediaDownloadsDir, key) if (target.exists()) { diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt index 12ffc1b3..d2b7a342 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt @@ -1039,6 +1039,10 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( } else { throw e } + } finally { + // A download attempt may have created, replaced, or deleted this url's cached + // media file — drop the memoized media state so the next probe re-stats disk. + imageCache.invalidateMediaState(imageUrl) } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt index defe1302..f006dfd9 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt @@ -134,14 +134,22 @@ fun ReaderImageView( val aspectRatioModifier = Modifier.imageAspectRatio(side, effectiveWidth, effectiveHeight) val hasResolvedAspectRatio = effectiveDimensions != null - // Single, idempotent cache probe used to seed loading state and the loading UI choice. - // Performed once per imageUrl on the Composition thread (down from 3–4 File ops previously). - // Coil's HttpMediaCacheFetcher owns the authoritative disk check inside its own dispatcher. + // Single, idempotent cache probe shared with the request cache key below (one + // getLikelyMediaState call per composition entry — the repository memoizes it, so item + // re-entries during a scroll don't re-stat disk on the main thread). Coil's + // HttpMediaCacheFetcher owns the authoritative disk check inside its own dispatcher. + var retryTrigger by remember(imageUrl, pageUrl) { mutableStateOf(0L) } + val localMediaState = remember(imageUrl, retryTrigger) { + if (imageUrl.startsWith("http")) { + readerViewModel.contentRepository.getLikelyMediaState(imageUrl) + } else { + "" + } + } val isInitiallyCached = remember(imageUrl) { when { imageUrl.startsWith("file") -> true - imageUrl.startsWith("http") -> - readerViewModel.contentRepository.getLikelyMediaState(imageUrl) != "missing" + imageUrl.startsWith("http") -> localMediaState != "missing" else -> false } } @@ -214,14 +222,6 @@ fun ReaderImageView( isCached = isInitiallyCached ) - var retryTrigger by remember(imageUrl, pageUrl) { mutableStateOf(0L) } - val localMediaState = remember(imageUrl, retryTrigger) { - if (imageUrl.startsWith("http")) { - readerViewModel.contentRepository.getLikelyMediaState(imageUrl) - } else { - "" - } - } // Keys are only the inputs that actually change the request. isInitiallyCached is stable // for the lifetime of the composition, so it does not need to be a key — capturing the // value once avoids the re-fetch loop that previously fired when the success handler diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt index 05ecc665..7ef1be9d 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt @@ -98,6 +98,59 @@ class ImageCacheTest { assertEquals(null, result) } + @Test + fun `getLikelyMediaState memoizes until invalidated`() { + val url = "https://example.com/memo.jpg" + assertEquals("missing", imageCache.getLikelyMediaState(url)) + + // File appears on disk behind the memo's back: the memoized "missing" is served... + val file = File(cacheDir, CacheKeyUtils.keyFor(url)).apply { writeBytes(validJpegBytes()) } + assertEquals("missing", imageCache.getLikelyMediaState(url)) + + // ...until the url is invalidated (as every production write path does). + imageCache.invalidateMediaState(url) + val state = imageCache.getLikelyMediaState(url) + assertTrue(state.startsWith(file.absolutePath)) + } + + @Test + fun `deleteCachedMediaFiles refreshes media state`() { + val url = "https://example.com/delete-refresh.jpg" + File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes()) + assertNotEquals("missing", imageCache.getLikelyMediaState(url)) + + imageCache.deleteCachedMediaFiles(url) + + assertEquals("missing", imageCache.getLikelyMediaState(url)) + } + + @Test + fun `clearAll and trimToSize drop the media state memo`() { + val url = "https://example.com/clear-refresh.jpg" + File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes()) + assertNotEquals("missing", imageCache.getLikelyMediaState(url)) + + imageCache.clearAll() + assertEquals("missing", imageCache.getLikelyMediaState(url)) + + File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes()) + assertNotEquals("missing", imageCache.getLikelyMediaState(url)) + imageCache.trimToSize(0) + assertEquals("missing", imageCache.getLikelyMediaState(url)) + } + + @Test + fun `promoteToDownloads refreshes media state to the downloads path`() { + val url = "https://example.com/promote-refresh.jpg" + val key = CacheKeyUtils.keyFor(url) + File(cacheDir, key).writeBytes(validJpegBytes()) + assertTrue(imageCache.getLikelyMediaState(url).startsWith(File(cacheDir, key).absolutePath)) + + imageCache.promoteToDownloads(url) + + assertTrue(imageCache.getLikelyMediaState(url).startsWith(File(downloadsDir, key).absolutePath)) + } + @Test fun `promoteToDownloads replaces invalid existing download target`() { val url = "https://example.com/promote.jpg" From 4b5340d567efc26408a936ad87e19e163d8d9a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:54:03 +0000 Subject: [PATCH 5/9] Perf(reader): drive image prefetch from snapshotFlow, not effect keys Keying the prefetch LaunchedEffect on listState.firstVisibleItemIndex read the index during composition, so every item boundary crossed while scrolling recomposed the entire ContentArea scope and cancelled+ relaunched the effect coroutine. Observe the index via snapshotFlow in one persistent effect per chapter instead; composition no longer subscribes to per-frame scroll state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../ui/screens/reader/ReaderContentArea.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt index 34b446cc..e71d6a8b 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt @@ -149,10 +149,18 @@ internal fun ContentArea( } } - LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) { - val currentIndex = if (uiState.isPagedMode) pagerState.currentPage else listState.firstVisibleItemIndex - prefetchImages(currentIndex, content, requestedIndices) { url -> - readerViewModel.prefetchVisibleImage(url, content.url) + // snapshotFlow instead of effect keys: reading firstVisibleItemIndex during composition + // recomposed this whole scope — and cancelled/relaunched the effect — on every item + // boundary crossed while scrolling. The captured `content` may be an older copy after a + // dimension rebuild, which is fine: prefetch only reads the element urls, identical + // across copies of the same chapter. + LaunchedEffect(content.url, uiState.isPagedMode) { + snapshotFlow { + if (uiState.isPagedMode) pagerState.currentPage else listState.firstVisibleItemIndex + }.collect { currentIndex -> + prefetchImages(currentIndex, content, requestedIndices) { url -> + readerViewModel.prefetchVisibleImage(url, content.url) + } } } From 8fb3e37c56ca9d0aa785ee3af55e7ff2e3773ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:57:25 +0000 Subject: [PATCH 6/9] Refactor(reader): reuse ChapterContent.getAllImageUrls for chapter prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing helper to recurse into PageContent (no callers relied on the old shallow behavior — it had none) instead of adding a fourth private copy of the Image/ImageGroup traversal in ReaderViewModel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../easyreader/data/model/ChapterContent.kt | 7 +++++-- .../easyreader/ui/viewmodel/ReaderViewModel.kt | 18 ++---------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt index 0e4aeabe..fab379e2 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt @@ -70,12 +70,15 @@ data class ChapterContent( ) /** - * Get all image URLs + * Get all image URLs, including images nested inside [ContentElement.PageContent] */ - fun getAllImageUrls(): List = paragraphs.flatMap { + fun getAllImageUrls(): List = collectImageUrls(paragraphs) + + private fun collectImageUrls(elements: List): List = elements.flatMap { when (it) { is ContentElement.Image -> listOf(it.url) is ContentElement.ImageGroup -> it.images.map { img -> img.url } + is ContentElement.PageContent -> collectImageUrls(it.elements) else -> emptyList() } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index 68341e54..f2aad122 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -108,20 +108,6 @@ class ReaderViewModel @Inject constructor( } } - private fun collectImageUrls(elements: List): Set { - val urls = mutableSetOf() - fun visit(element: ContentElement) { - when (element) { - is ContentElement.Image -> urls.add(element.url) - is ContentElement.ImageGroup -> element.images.forEach { urls.add(it.url) } - is ContentElement.PageContent -> element.elements.forEach(::visit) - else -> Unit - } - } - elements.forEach(::visit) - return urls - } - private fun ContentElement.withResolvedImageDimensions(updates: Map>): ContentElement { return when (this) { is ContentElement.Image -> { @@ -585,7 +571,7 @@ class ReaderViewModel @Inject constructor( // Only after a successful load — a failed navigation keeps the old chapter (and its // dimension state) on screen. Parse-time seeding from the Room cache restores anything // pruned here if the user navigates back. - imageDimensionManager.pruneForChapter(collectImageUrls(content.paragraphs)) + imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) val libraryItem = effectiveId?.let { libraryRepository.getItemById(it) } val baseTitle = getBaseTitle(content, libraryItem) @@ -877,7 +863,7 @@ class ReaderViewModel @Inject constructor( previousChapterUrl = chapter.previousHref?.let { "$epubPath#${it}" } ?: epubBook.getPreviousHref(href)?.let { "$epubPath#${it}" } ) - imageDimensionManager.pruneForChapter(collectImageUrls(content.paragraphs)) + imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) val tocChapterList = epubBook.getFlatToc() .map { ChapterInfo(title = it.title, url = "$epubPath#${it.href}") } From ac6930a09b7ff6dcc9e1cd4e924368eef04290fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 22:09:16 +0000 Subject: [PATCH 7/9] Fix(reader): close review-found races in memo invalidation and prune timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the perf series surfaced four timing holes, each confirmed by several independent passes: - Media-state memo invalidation moved from the download awaiter's finally into the shared deferred's invokeOnCompletion. The awaiter can be cancelled (Coil disposes a request when the item scrolls away) before the file lands; the detached download then wrote the file with no invalidation, leaving a permanent stale 'missing' entry — placeholder flash on every re-entry of a fully cached image. - ImageCache mutators now invalidate AFTER mutating disk; invalidating first let a concurrent probe re-memoize the pre-mutation state. - trimToSize only clears the memo when files were actually deleted (and after the walk). Trim runs after chapter loads and every ~30s during scroll-driven prefetch and is usually a no-op — the unconditional clear was periodically defeating the memoization outright. - getLikelyMediaState serves hits with a plain lookup; the size guard (which can clear the map) runs only on the miss/insert path, matching the integrityVerdicts pattern. - pruneForChapter now runs after the new content is committed to uiState, so the still-composed outgoing chapter is never stripped mid-display and late decodes cannot resurrect just-pruned entries; it also re-enqueues surviving urls into the content rebuild so a chapter sharing an image with its predecessor still gets its element healed despite the duplicate-persist guard. - A failed Room dimension flush re-queues its batch (persistAll now reports success); the duplicate-persist guard would otherwise swallow every future chance to retry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gzsfvi9WhP4KJDvAhEqR9y --- .../ImageDimensionCacheRepository.kt | 9 +- .../data/repository/content/ImageCache.kt | 34 ++++-- .../repository/content/WebContentLoader.kt | 10 +- .../ui/viewmodel/ImageDimensionManager.kt | 38 +++--- .../ui/viewmodel/ReaderViewModel.kt | 15 ++- .../aatricks/easyreader/util/FileSizeUtils.kt | 3 +- .../data/repository/content/ImageCacheTest.kt | 15 +++ .../ui/viewmodel/ImageDimensionManagerTest.kt | 109 +++++++++++++----- 8 files changed, 166 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt index eaa702ce..c4ae9be9 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt @@ -41,8 +41,9 @@ class ImageDimensionCacheRepository @Inject constructor( } } - suspend fun persistAll(entries: List>) { - if (entries.isEmpty()) return + /** @return true when the rows reached the DB (or there was nothing valid to write). */ + suspend fun persistAll(entries: List>): Boolean { + if (entries.isEmpty()) return true val now = System.currentTimeMillis() val rows = entries .filter { it.first.isNotBlank() && it.second > 0 && it.third > 0 } @@ -55,8 +56,8 @@ class ImageDimensionCacheRepository @Inject constructor( parserVersion = CURRENT_PARSER_VERSION ) } - if (rows.isEmpty()) return - runCatching { dao.upsertAll(rows) } + if (rows.isEmpty()) return true + return runCatching { dao.upsertAll(rows) }.isSuccess } suspend fun prune(ttlMs: Long = DEFAULT_TTL_MS) { diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt index 94688324..126cf9db 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt @@ -30,6 +30,10 @@ class ImageCache @Inject constructor( // and WebContentLoader.downloadAndCacheImageInternal); staleness is otherwise harmless // because HttpMediaCacheFetcher re-checks disk authoritatively before serving. fun getLikelyMediaState(url: String): String { + // Hit path stays lookup-only (same shape as isCachedImageValid below): the size guard + // both costs a CHM sweep and, when it trips, would wipe the live chapter's hot entries + // on what was a plain read. + mediaStateMemo[url]?.let { return it } if (mediaStateMemo.size > MAX_MEDIA_STATE_MEMO) mediaStateMemo.clear() return mediaStateMemo.computeIfAbsent(url) { computeLikelyMediaState(it) } } @@ -50,10 +54,15 @@ class ImageCache @Inject constructor( fun getDownloadsSize(): Long = FileSizeUtils.calculateDirectorySize(mediaDownloadsDir) fun trimToSize(maxBytes: Long): Long { - // Trim deletes arbitrary cache files; there is no per-url mapping back, so drop the - // whole media-state memo. - mediaStateMemo.clear() - return FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) + // Trim runs routinely (after chapter loads, every ~30s during prefetch) and usually + // deletes nothing — the memo must survive those calls or the memoization is defeated. + // When files ARE deleted there is no per-url mapping back from file names, so drop the + // whole memo, and only AFTER the walk finishes: clearing first would let a concurrent + // probe re-memoize a file the walk is about to delete. + var deletedAny = false + val remaining = FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) { deletedAny = true } + if (deletedAny) mediaStateMemo.clear() + return remaining } fun findExistingCachedMediaFile(url: String): File? = @@ -96,30 +105,37 @@ class ImageCache @Inject constructor( private const val MAX_MEDIA_STATE_MEMO = 4096 } + // Memo invalidation always FOLLOWS the disk mutation: invalidating first leaves a window + // where a concurrent probe re-memoizes the pre-mutation state, which then sticks. fun deleteCachedMediaFiles(url: String) { - mediaStateMemo.remove(url) candidateFiles(url).forEach { it.delete() } + mediaStateMemo.remove(url) } fun deleteDownloadedMediaFile(url: String) { - mediaStateMemo.remove(url) File(mediaDownloadsDir, CacheKeyUtils.keyFor(url)).delete() + mediaStateMemo.remove(url) } fun clearAll() { - mediaStateMemo.clear() mediaCacheDir.deleteRecursively() mediaCacheDir.mkdirs() + mediaStateMemo.clear() } fun clearAllDownloads() { - mediaStateMemo.clear() mediaDownloadsDir.deleteRecursively() mediaDownloadsDir.mkdirs() + mediaStateMemo.clear() } - fun promoteToDownloads(url: String): File? { + fun promoteToDownloads(url: String): File? = try { + promoteToDownloadsInternal(url) + } finally { mediaStateMemo.remove(url) + } + + private fun promoteToDownloadsInternal(url: String): File? { val key = CacheKeyUtils.keyFor(url) val target = File(mediaDownloadsDir, key) if (target.exists()) { diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt index d2b7a342..16924de4 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt @@ -1018,6 +1018,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( } }.also { created -> created.invokeOnCompletion { + // Invalidate here — when the download attempt itself finishes — not in + // the awaiter: the deferred is shared and outlives a cancelled awaiter + // (Coil disposing a request mid-download), and an awaiter-side + // invalidation would fire before the file lands, letting a probe + // re-memoize "missing" that nothing ever corrects. + imageCache.invalidateMediaState(imageUrl) repositoryScope.launch { imageDownloadMutex.withLock { if (inFlightImageDownloads[imageUrl]?.deferred === created) { @@ -1039,10 +1045,6 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( } else { throw e } - } finally { - // A download attempt may have created, replaced, or deleted this url's cached - // media file — drop the memoized media state so the next probe re-stats disk. - imageCache.invalidateMediaState(imageUrl) } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index b2b5c7ae..313310df 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import android.util.Log -import io.aatricks.easyreader.util.rethrowCancellation import java.util.concurrent.ConcurrentHashMap /** @@ -52,9 +51,6 @@ class ImageDimensionManager( fun dimensionState(imageUrl: String): State?> = dimensionStates.computeIfAbsent(imageUrl) { mutableStateOf(null) } - /** Non-observing read for tests and non-compose callers. */ - fun resolvedDimensions(imageUrl: String): Pair? = dimensionStates[imageUrl]?.value - fun persistImageDimensions(imageUrl: String, width: Int, height: Int) { if (imageUrl.isBlank() || width <= 0 || height <= 0) return val dims = width to height @@ -77,7 +73,7 @@ class ImageDimensionManager( dimensionFlushJob = scope.launch { delay(IMAGE_DIMENSION_FLUSH_DELAY_MS) while (pendingImageDimensions.isNotEmpty()) { - flushPendingImageDimensions() + if (!flushPendingImageDimensions()) break } } } @@ -100,21 +96,26 @@ class ImageDimensionManager( } } - private suspend fun flushPendingImageDimensions() { + /** @return false when the write failed; the batch is re-queued for a later flush. */ + private suspend fun flushPendingImageDimensions(): Boolean { val updates = pendingImageDimensions.toMap() pendingImageDimensions.clear() - if (updates.isEmpty()) return - runCatching { - imageDimensionCache.persistAll(updates.map { (url, dimensions) -> - Triple(url, dimensions.first, dimensions.second) - }) - }.rethrowCancellation().onFailure { e -> - Log.e(TAG, "Failed to flush pending image dimensions", e) + if (updates.isEmpty()) return true + val persisted = imageDimensionCache.persistAll(updates.map { (url, dimensions) -> + Triple(url, dimensions.first, dimensions.second) + }) + if (!persisted) { + // Re-queue: the duplicate-persist guard means no future onSuccess will re-enqueue + // these dims itself, so dropping the batch here would lose them for the session. + // putIfAbsent keeps any newer value that arrived during the failed write. + updates.forEach { (url, dims) -> pendingImageDimensions.putIfAbsent(url, dims) } + Log.e(TAG, "Failed to flush pending image dimensions; batch re-queued") } + return persisted } /** - * Called when a new chapter's content replaces the old one. Drops per-URL state for images + * Called after a new chapter's content replaces the old one. Drops per-URL state for images * that are not part of the new chapter — without this the manager grows unboundedly for the * life of the (Activity-scoped) ReaderViewModel, one entry per image ever displayed. Keeping * the new chapter's own urls means a same-chapter reload keeps its dims. The db-flush job / @@ -123,8 +124,15 @@ class ImageDimensionManager( */ fun pruneForChapter(currentImageUrls: Set) { dimensionStates.keys.retainAll(currentImageUrls) - contentDimApplyJob?.cancel() contentDimUpdates.keys.retainAll(currentImageUrls) + // Re-enqueue the survivors' resolved dims: a url shared with the previous chapter can + // hold dimensions here while the new chapter's element still lacks them (the Room seed + // raced the 100ms flush), and the duplicate-persist guard would otherwise block the + // content rebuild from ever healing that element. + dimensionStates.forEach { (url, state) -> + val dims = state.value ?: return@forEach + contentDimUpdates.putIfAbsent(url, dims) + } if (contentDimUpdates.isNotEmpty()) scheduleContentDimApply() } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index f2aad122..46f335f5 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -568,11 +568,6 @@ class ReaderViewModel @Inject constructor( preCalculatedTextCount = result.textCount, preCalculatedImageCount = result.imageCount ) - // Only after a successful load — a failed navigation keeps the old chapter (and its - // dimension state) on screen. Parse-time seeding from the Room cache restores anything - // pruned here if the user navigates back. - imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) - val libraryItem = effectiveId?.let { libraryRepository.getItemById(it) } val baseTitle = getBaseTitle(content, libraryItem) val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" } @@ -637,6 +632,12 @@ class ReaderViewModel @Inject constructor( } syncProgressState(initialPosition) + // Prune only AFTER the new content is committed to uiState: during the (suspending) + // load above the old chapter is still composed, and pruning it early would strip its + // shared dimensions mid-display while late decodes re-inserted just-pruned entries. + // A failed load never reaches this line, so an on-screen chapter is never pruned. + imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) + updateNavigationUrls() maybeWarmNextChapter(_uiState.value.content?.nextChapterUrl) @@ -863,7 +864,6 @@ class ReaderViewModel @Inject constructor( previousChapterUrl = chapter.previousHref?.let { "$epubPath#${it}" } ?: epubBook.getPreviousHref(href)?.let { "$epubPath#${it}" } ) - imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) val tocChapterList = epubBook.getFlatToc() .map { ChapterInfo(title = it.title, url = "$epubPath#${it.href}") } @@ -908,6 +908,9 @@ class ReaderViewModel @Inject constructor( } syncProgressState(initialPosition) + // After the content swap, for the same reasons as in handleLoadSuccess. + imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet()) + preferencesManager.batchUpdateLastRead(content.url, effectiveLibraryItemId) effectiveLibraryItemId?.let { id -> diff --git a/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt b/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt index afd08331..86783a74 100644 --- a/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt +++ b/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt @@ -28,7 +28,7 @@ object FileSizeUtils { return size } - fun trimDirectoryToSize(dir: File, maxBytes: Long): Long { + fun trimDirectoryToSize(dir: File, maxBytes: Long, onDelete: (File) -> Unit = {}): Long { if (!dir.exists()) return 0L val files = dir.walkTopDown() .filter { it.isFile && !it.name.endsWith(".tmp") } @@ -41,6 +41,7 @@ object FileSizeUtils { val length = file.length() if (file.delete()) { total -= length + onDelete(file) } } return total diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt index 7ef1be9d..dfeab037 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt @@ -139,6 +139,21 @@ class ImageCacheTest { assertEquals("missing", imageCache.getLikelyMediaState(url)) } + @Test + fun `trimToSize keeps the memo when nothing is deleted`() { + val url = "https://example.com/trim-noop.jpg" + val file = File(cacheDir, CacheKeyUtils.keyFor(url)).apply { writeBytes(validJpegBytes()) } + val memoized = imageCache.getLikelyMediaState(url) + + // Delete behind the memo's back, then trim with a budget that deletes nothing: + // the memo must survive (trim runs every ~30s during prefetch — wiping it on + // no-op trims would defeat the memoization). + file.delete() + imageCache.trimToSize(Long.MAX_VALUE) + + assertEquals(memoized, imageCache.getLikelyMediaState(url)) + } + @Test fun `promoteToDownloads refreshes media state to the downloads path`() { val url = "https://example.com/promote-refresh.jpg" diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt index 7399b528..4b61bc29 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt @@ -1,5 +1,8 @@ package io.aatricks.easyreader.ui.viewmodel +import io.aatricks.easyreader.data.local.ImageDimensionDao +import io.aatricks.easyreader.data.model.ImageDimensionEntity +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -7,28 +10,22 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ImageDimensionManagerTest { - private class RecordingApplier { - val batches = mutableListOf>>() - fun apply(updates: Map>) { - batches.add(updates) - } - } - @Test fun `persist stores dimensions and applies content rebuild once`() = runTest { - val applier = RecordingApplier() - val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } manager.persistImageDimensions("http://img/1.jpg", 800, 1200) advanceUntilIdle() - assertEquals(800 to 1200, manager.resolvedDimensions("http://img/1.jpg")) - assertEquals(listOf(mapOf("http://img/1.jpg" to (800 to 1200))), applier.batches) + assertEquals(800 to 1200, manager.dimensionState("http://img/1.jpg").value) + assertEquals(listOf(mapOf("http://img/1.jpg" to (800 to 1200))), batches) } @Test @@ -47,8 +44,8 @@ class ImageDimensionManagerTest { @Test fun `duplicate persist of identical dimensions is a no-op`() = runTest { - val applier = RecordingApplier() - val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } manager.persistImageDimensions("http://img/1.jpg", 800, 1200) advanceUntilIdle() @@ -57,22 +54,22 @@ class ImageDimensionManagerTest { manager.persistImageDimensions("http://img/1.jpg", 800, 1200) advanceUntilIdle() - assertEquals(1, applier.batches.size) + assertEquals(1, batches.size) } @Test fun `changed dimensions for the same url go through`() = runTest { - val applier = RecordingApplier() - val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } manager.persistImageDimensions("http://img/1.jpg", 800, 1200) advanceUntilIdle() manager.persistImageDimensions("http://img/1.jpg", 900, 1600) advanceUntilIdle() - assertEquals(900 to 1600, manager.resolvedDimensions("http://img/1.jpg")) - assertEquals(2, applier.batches.size) - assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), applier.batches[1]) + assertEquals(900 to 1600, manager.dimensionState("http://img/1.jpg").value) + assertEquals(2, batches.size) + assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), batches[1]) } @Test @@ -85,14 +82,14 @@ class ImageDimensionManagerTest { manager.pruneForChapter(setOf("http://kept/2.jpg", "http://new/3.jpg")) - assertNull(manager.resolvedDimensions("http://old/1.jpg")) - assertEquals(900 to 1400, manager.resolvedDimensions("http://kept/2.jpg")) + assertNull(manager.dimensionState("http://old/1.jpg").value) + assertEquals(900 to 1400, manager.dimensionState("http://kept/2.jpg").value) } @Test fun `pruneForChapter reschedules content apply only for surviving urls`() = runTest { - val applier = RecordingApplier() - val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } manager.persistImageDimensions("http://old/1.jpg", 800, 1200) manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) @@ -101,20 +98,76 @@ class ImageDimensionManagerTest { manager.pruneForChapter(setOf("http://kept/2.jpg")) advanceUntilIdle() - assertEquals(listOf(mapOf("http://kept/2.jpg" to (900 to 1400))), applier.batches) + assertEquals(listOf(mapOf("http://kept/2.jpg" to (900 to 1400))), batches) + } + + @Test + fun `pruneForChapter re-enqueues surviving dims for the content rebuild`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + advanceUntilIdle() + assertEquals(1, batches.size) + + // A url shared with the previous chapter keeps its state, but the new chapter's + // element may still lack dimensions — prune must schedule a fresh apply because the + // duplicate-persist guard blocks any future decode from doing so. + manager.pruneForChapter(setOf("http://kept/2.jpg")) + advanceUntilIdle() + + assertEquals(2, batches.size) + assertEquals(mapOf("http://kept/2.jpg" to (900 to 1400)), batches[1]) + } + + @Test + fun `failed db flush re-queues the batch and retries on the next flush`() = runTest { + val dao = FlakyImageDimensionDao() + val manager = ImageDimensionManager(this, ImageDimensionCacheRepository(dao)) {} + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + assertTrue(dao.upserted.isEmpty()) + + // Without the re-queue, the duplicate-persist guard would swallow every later + // re-report of these dims and they would never reach the DB this session. + dao.failWrites = false + manager.persistImageDimensions("http://img/2.jpg", 700, 1000) + advanceUntilIdle() + + assertEquals(setOf("http://img/1.jpg", "http://img/2.jpg"), dao.upserted.keys) } @Test fun `blank url and non-positive dimensions are rejected`() = runTest { - val applier = RecordingApplier() - val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository(), applier::apply) + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } manager.persistImageDimensions("", 800, 1200) manager.persistImageDimensions("http://img/1.jpg", 0, 1200) manager.persistImageDimensions("http://img/1.jpg", 800, -1) advanceUntilIdle() - assertNull(manager.resolvedDimensions("http://img/1.jpg")) - assertEquals(0, applier.batches.size) + assertNull(manager.dimensionState("http://img/1.jpg").value) + assertEquals(0, batches.size) + } + + private class FlakyImageDimensionDao : ImageDimensionDao { + var failWrites = true + val upserted = mutableMapOf() + + override suspend fun getMany(urls: List, parserVersion: Int): List = + emptyList() + + override suspend fun upsert(entity: ImageDimensionEntity) { + upserted[entity.imageUrl] = entity + } + + override suspend fun upsertAll(entities: List) { + if (failWrites) throw RuntimeException("disk full") + entities.forEach { upserted[it.imageUrl] = it } + } + + override suspend fun prune(cutoffMs: Long, currentParserVersion: Int) = Unit } } From 04b598e953ae892a4e4af8b59c8a9b23a26267bd Mon Sep 17 00:00:00 2001 From: Aatricks Date: Sun, 5 Jul 2026 18:39:59 -0400 Subject: [PATCH 8/9] Refactor(detekt): remove unused magic numbers from ChapterDownloadQueue Refactor(tests): invalidate media state in ImageCacheTest after writing bytes Build(gradle): update gradle wrapper properties and scripts for consistency --- app/detekt-baseline.xml | 6 ++---- .../data/repository/content/ImageCacheTest.kt | 1 + gradle/wrapper/gradle-wrapper.jar | Bin 45457 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 1 - gradlew | 5 +---- gradlew.bat | 3 +-- 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index ba21243d..45f3e2f4 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -301,10 +301,7 @@ MagicNumber:BaseJsoupSource.kt$BaseJsoupSource$15000L MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$0xff MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$4 - MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$3 MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$30 - MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$4 - MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$5 MagicNumber:ChapterListSheet.kt$100 MagicNumber:EasyReaderApplication.kt$EasyReaderApplication$0.25 MagicNumber:EpubContentLoader.kt$EpubContentLoader$1024 @@ -630,8 +627,9 @@ ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseJpeg(bytes: ByteArray): Pair<Int, Int>? ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parsePng(bytes: ByteArray): Pair<Int, Int>? ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseWebP(bytes: ByteArray): Pair<Int, Int>? - ReturnCount:ImageCache.kt$ImageCache$fun promoteToDownloads(url: String): File? ReturnCount:ImageCache.kt$ImageCache$private fun File.isCachedImageValid(): Boolean + ReturnCount:ImageCache.kt$ImageCache$private fun promoteToDownloadsInternal(url: String): File? + ReturnCount:ImageDimensionCacheRepository.kt$ImageDimensionCacheRepository$suspend fun persistAll(entries: List<Triple<String, Int, Int>>): Boolean ReturnCount:ImageDownloader.kt$ImageDownloader$private fun parseImageResponse( response: Response, destinationFile: File? = null ): ImageFetchResult ReturnCount:ImageDownloader.kt$ImageDownloader$suspend fun executeImageRequest( imageUrl: String, pageUrl: String, priority: ImageRequestPriority, rangeHeader: String? = null, destinationFile: File? = null ): ImageFetchResult ReturnCount:ImageIntegrity.kt$ImageIntegrity$fun isValidImageFile(file: File): Boolean diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt index dfeab037..054a5c2c 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt @@ -134,6 +134,7 @@ class ImageCacheTest { assertEquals("missing", imageCache.getLikelyMediaState(url)) File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes()) + imageCache.invalidateMediaState(url) assertNotEquals("missing", imageCache.getLikelyMediaState(url)) imageCache.trimToSize(0) assertEquals("missing", imageCache.getLikelyMediaState(url)) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60c75ab801e22807dde59e12a8735a34077..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch delta 39724 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ⅆA{k6{Sa_dPB>TF4Im=mf;z@rf2j7Tr#P>Bp9qL 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 delta 36423 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 82c2070d..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,3 @@ -#Thu Oct 23 12:43:41 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip diff --git a/gradlew b/gradlew index ef07e016..739907df 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From fa3bae44f4e7b4749fa861b2338825129bb694fd Mon Sep 17 00:00:00 2001 From: Aatricks Date: Mon, 6 Jul 2026 01:44:17 -0400 Subject: [PATCH 9/9] Perf(reader): stop manhwa scroll degrading after chapters and drawer use Long-strip scroll micro-stuttered on fast up/down drags, got worse after a chapter or two, and stuttered almost every time after opening then closing the library drawer. - Coil memory cache raised 0.25 -> 0.5 of heap. Strips are region-decoded into ~9MB HARDWARE slices, so at 0.25 the LRU held under one chapter's worth and a fast up/down drag or scroll-back re-decoded evicted slices every time. - Reader drawer now reads a dedicated lean flow (sections + isEmpty) instead of the whole-library uiState combine, so opening it no longer group+sorts the entire library (getGroupedByTitle/BySource) on the drawer path. - Restore edge-swipe-to-open the drawer in continuous scroll; paged mode keeps the left edge for page turns. - Revert an unrelated restore-fallback change that dropped the percent self-heal for precise restores, and restore its tests. Supporting work kept from the same effort: library-state combine moved off the main thread with a lifecycle-scoped subscription, nested-scroll-connection and progress-save reworks to cut per-frame work during scroll, and an e2e suite covering scroll, drawer and state restoration. --- app/detekt-baseline.xml | 8 +- .../easyreader/EasyReaderApplication.kt | 14 +- .../screens/library/LibraryDrawerContent.kt | 91 +- .../ui/screens/library/LibraryNovelHelpers.kt | 4 +- .../ui/screens/reader/ReaderContentArea.kt | 76 +- .../screens/reader/ReaderContentGestures.kt | 71 +- .../ui/screens/reader/ReaderScreen.kt | 42 +- .../easyreader/ui/viewmodel/BaseViewModel.kt | 2 +- .../ui/viewmodel/LibraryViewModel.kt | 145 ++- .../ui/viewmodel/ReaderProgressController.kt | 65 +- .../easyreader/e2e/ReaderEndToEndTest.kt | 949 ++++++++++++++++++ .../ui/viewmodel/LibraryViewModelTest.kt | 7 +- 12 files changed, 1257 insertions(+), 217 deletions(-) create mode 100644 app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 45f3e2f4..87d170c2 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -70,7 +70,7 @@ FunctionNaming:FileConfirmationDialog.kt$@Composable fun FileConfirmationDialog( fileUri: String, onConfirm: () -> Unit, onCancel: () -> Unit ) FunctionNaming:GridStateTiles.kt$@Composable fun ErrorTile( message: String, onRetry: (() -> Unit)? = null, modifier: Modifier = Modifier ) FunctionNaming:GridStateTiles.kt$@Composable fun LoadingTile( modifier: Modifier = Modifier, height: androidx.compose.ui.unit.Dp = 180.dp ) - FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit ): Unit + FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit ): Unit FunctionNaming:LibraryDrawerContent.kt$@Composable private fun ContinueReadingCard( item: LibraryItem, onClick: () -> Unit ): Unit FunctionNaming:LibraryDrawerContent.kt$@Composable private fun DrawerSectionLabel(text: String): Unit FunctionNaming:LibraryDrawerContent.kt$@Composable private fun EmptyQuickAccessState(): Unit @@ -149,7 +149,7 @@ LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreScreen( exploreViewModel: ExploreViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, onReadItem: (ExploreItem) -> Unit ): Unit LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ActiveFilterBar( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearFilters: () -> Unit ): Unit LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun FiltersBottomSheetContent( uiState: ExploreViewModel.ExploreUiState, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearTags: () -> Unit, onClose: () -> Unit ): Unit - LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit ): Unit + LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit ): Unit LongMethod:LibraryItemCard.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryItemCard( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null ) LongMethod:LibraryRepository.kt$LibraryRepository$suspend fun refreshLibraryUpdates( exploreRepository: ExploreRepository, ignoreActivityThreshold: Boolean = false ): Unit LongMethod:LibraryScreen.kt$@Composable private fun AddNovelSection( urlInput: String, onUrlChange: (String) -> Unit, onAddClick: () -> Unit, onOpenPdfClick: () -> Unit ): Unit @@ -184,6 +184,7 @@ LongParameterList:ExploreGridSection.kt$( gridState: LazyGridState, modifier: Modifier = Modifier, uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onItemSelect: (ExploreItem) -> Unit, onLoadMore: () -> Unit, onClearFilters: () -> Unit, onRetryFailedSource: (String) -> Unit ) LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearFilters: () -> Unit ) LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, modifier: Modifier = Modifier, hasActiveFilters: Boolean, onSearchQueryChange: (String) -> Unit, onPerformSearch: () -> Unit, onOpenFilters: () -> Unit, onClearFilters: () -> Unit, onSetBrowseMode: (BrowseMode) -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onItemSelect: (ExploreItem) -> Unit, onLoadMore: () -> Unit, onRetryFailedSource: (String) -> Unit ) + LongParameterList:LibraryDrawerContent.kt$( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit ) LongParameterList:LibraryItemCard.kt$( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null ) LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String = "", progress: FieldUpdate<Int> = FieldUpdate.Unchanged, currentChapterUrl: FieldUpdate<String> = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate<Float> = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate<Int> = FieldUpdate.Unchanged, lastReadElementKey: FieldUpdate<String> = FieldUpdate.Unchanged, lastReadOffsetFraction: FieldUpdate<Float> = FieldUpdate.Unchanged ) LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String, progress: Int, currentChapterUrl: String? = null, lastScrollProgress: Float? = null, lastReadIndex: Int? = null, lastReadElementKey: String? = null, lastReadOffsetFraction: Float? = null ) @@ -303,7 +304,6 @@ MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$4 MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$30 MagicNumber:ChapterListSheet.kt$100 - MagicNumber:EasyReaderApplication.kt$EasyReaderApplication$0.25 MagicNumber:EpubContentLoader.kt$EpubContentLoader$1024 MagicNumber:EpubContentLoader.kt$EpubContentLoader$50 MagicNumber:EpubContentLoader.kt$EpubContentLoader.<no name provided>$5 @@ -576,6 +576,8 @@ MaxLineLength:ReaderProgressController.kt$ReaderProgressController$"saveProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(resolvedChapterUrl)} index=$lastIndex elementKey=${if (lastElementKey.isNotEmpty()) "<set>" else "<empty>"} fraction=$lastFraction firstVisibleItemSize=${latest.firstVisibleItemSize}" MaxLineLength:ReaderProgressController.kt$ReaderProgressController$maxScrollOffset > viewportHeight -> ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f) MaxLineLength:ReaderProgressController.kt$ReaderProgressController$val nextUiPendingRestoreOffsetFraction = if (uiPendingRestoreOffsetFraction != null) null else uiPendingRestoreOffsetFraction + MaxLineLength:ReaderScreen.kt$libraryViewModel + MaxLineLength:ReaderScreen.kt$val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip snap-to-top url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} dbProgress=${existing.progress}" MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip unstable url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} firstVisibleItemSize=${latest.firstVisibleItemSize} fraction=${latest.scrollOffsetFraction}" MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} index=${latest.scrollIndex} fraction=${latest.scrollOffsetFraction} firstVisibleItemSize=${latest.firstVisibleItemSize}" diff --git a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt index de063b87..925db0bb 100644 --- a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt +++ b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt @@ -138,13 +138,25 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Confi } private fun buildMemoryCache(context: PlatformContext): MemoryCache { + // Manhwa strips are region-decoded into HARDWARE-config slices (~screenWidth x 2048 x 4B + // ≈ 9MB each). At 0.25 the strong LRU (~32-64MB) holds only ~4-7 slices — well under one + // chapter, so a fast up/down drag or scrolling back re-decodes evicted slices every time. + // HARDWARE pixels live in graphics memory (AHardwareBuffer), not the Java heap, so a larger + // budget does not raise heap-OOM risk; 0.5 comfortably holds a full oscillation window. return MemoryCache.Builder() - .maxSizePercent(context, 0.25) + .maxSizePercent(context, MEMORY_CACHE_HEAP_FRACTION) .build() } companion object { private const val TAG = "EasyReaderApplication" private const val WEB_OFFLINE_PIPELINE_VERSION = 3 + + // Fraction of the app's available memory the Coil image cache may use. Manhwa strips are + // decoded into HARDWARE-config slices (~9MB each, in graphics memory not the Java heap), so + // a larger budget lets a full up/down oscillation window and >1 chapter's slices stay + // resident instead of being evicted and re-decoded on scroll-back. Dial back toward 0.25 if + // low-memory devices hit onTrimMemory / AHardwareBuffer-fd pressure. + private const val MEMORY_CACHE_HEAP_FRACTION = 0.5 } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt index ba1c91de..94e51a33 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt @@ -12,35 +12,26 @@ import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import io.aatricks.easyreader.data.model.LibraryItem -import io.aatricks.easyreader.ui.ExploreRoute -import io.aatricks.easyreader.ui.LibraryRoute import io.aatricks.easyreader.ui.theme.EasyReaderSpacing -import io.aatricks.easyreader.ui.viewmodel.LibraryViewModel -import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel @Composable fun LibraryDrawerContent( - libraryViewModel: LibraryViewModel, - readerViewModel: ReaderViewModel, - navController: NavController, + drawerSections: DrawerNovelSections, + isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, - onCloseDrawer: () -> Unit + onCloseDrawer: () -> Unit, + onLibraryClick: () -> Unit, + onDiscoverClick: () -> Unit, + onOpenLibraryItem: (LibraryItem) -> Unit, + onOpenLatestUpdate: (LibraryItem) -> Unit ): Unit { - val libraryUiState by libraryViewModel.uiState.collectAsState() - val drawerSections = remember(libraryUiState.items) { - buildDrawerNovelSections(libraryUiState.items) - } val continueNovel = drawerSections.continueNovel val recentUpdates = drawerSections.recentUpdates val recentItems = drawerSections.recentNovels @@ -60,9 +51,7 @@ fun LibraryDrawerContent( FilledTonalButton( onClick = { onCloseDrawer() - navController.navigate(LibraryRoute) { - launchSingleTop = true - } + onLibraryClick() }, modifier = Modifier.weight(1f) ) { @@ -71,9 +60,7 @@ fun LibraryDrawerContent( OutlinedButton( onClick = { onCloseDrawer() - navController.navigate(ExploreRoute) { - launchSingleTop = true - } + onDiscoverClick() }, modifier = Modifier.weight(1f) ) { @@ -105,12 +92,8 @@ fun LibraryDrawerContent( ContinueReadingCard( item = continueNovel.resumeItem, onClick = { - openLibraryItem( - item = continueNovel.resumeItem, - libraryViewModel = libraryViewModel, - readerViewModel = readerViewModel, - onCloseDrawer = onCloseDrawer - ) + onOpenLibraryItem(continueNovel.resumeItem) + onCloseDrawer() } ) } @@ -124,12 +107,8 @@ fun LibraryDrawerContent( supportingText = "Start at the newest chapter", trailingLabel = "Open latest", onClick = { - openLatestUpdateItem( - item = novel.updateItem, - libraryViewModel = libraryViewModel, - readerViewModel = readerViewModel, - onCloseDrawer = onCloseDrawer - ) + onOpenLatestUpdate(novel.updateItem) + onCloseDrawer() } ) } @@ -145,18 +124,14 @@ fun LibraryDrawerContent( if (chapter.startsWith("Resume")) chapter else "Resume $chapter" }, onClick = { - openLibraryItem( - item = novel.resumeItem, - libraryViewModel = libraryViewModel, - readerViewModel = readerViewModel, - onCloseDrawer = onCloseDrawer - ) + onOpenLibraryItem(novel.resumeItem) + onCloseDrawer() } ) } } - if (libraryUiState.items.isEmpty()) { + if (isLibraryEmpty) { item { EmptyQuickAccessState() } @@ -164,40 +139,6 @@ fun LibraryDrawerContent( } } -private fun openLibraryItem( - item: LibraryItem, - libraryViewModel: LibraryViewModel, - readerViewModel: ReaderViewModel, - onCloseDrawer: () -> Unit -): Unit { - val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url - readerViewModel.loadContent(loadUrl, item.id) - libraryViewModel.markAsCurrentlyReading(item.id) - onCloseDrawer() -} - -private fun openLatestUpdateItem( - item: LibraryItem, - libraryViewModel: LibraryViewModel, - readerViewModel: ReaderViewModel, - onCloseDrawer: () -> Unit -): Unit { - val baseTitle = item.baseTitle.ifBlank { item.title } - if (item.baseNovelUrl.isBlank() || item.sourceName.isBlank()) { - val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url - readerViewModel.openChapterFromStart(loadUrl, item.id) - libraryViewModel.markAsCurrentlyReading(item.id) - onCloseDrawer() - return - } - - libraryViewModel.openNewChapter(baseTitle, item.baseNovelUrl, item.sourceName) { url, id -> - readerViewModel.openChapterFromStart(url, id) - libraryViewModel.markAsCurrentlyReading(id) - onCloseDrawer() - } -} - @Composable private fun ContinueReadingCard( item: LibraryItem, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt index 503b19aa..aec835f8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt @@ -9,7 +9,7 @@ import io.aatricks.easyreader.data.model.libraryNovelKey import io.aatricks.easyreader.data.model.resolvedChapterNumber import io.aatricks.easyreader.data.model.seriesReadingStatus -internal data class DrawerNovelEntry( +data class DrawerNovelEntry( val novelKey: String, val displayTitle: String, val resumeItem: LibraryItem, @@ -20,7 +20,7 @@ internal data class DrawerNovelEntry( val updateTimestamp: Long ) -internal data class DrawerNovelSections( +data class DrawerNovelSections( val continueNovel: DrawerNovelEntry?, val recentUpdates: List, val recentNovels: List diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt index e71d6a8b..9f63609c 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt @@ -102,6 +102,12 @@ internal fun ContentArea( isManhwaByUrl || (content.getImageCount() > content.getTextCount() && content.getImageCount() > 2) } + val stableKeys = remember(content) { + content.paragraphs.mapIndexed { idx, element -> + stableContentElementKey(content.url, idx, element) + } + } + // No init values — single restore path through LaunchedEffect below. Initial values would // race with the LaunchedEffect and create the "two paths, one of them silently wrong" bug. val listState = key(content.url) { rememberLazyListState() } @@ -165,7 +171,7 @@ internal fun ContentArea( } LaunchedEffect(content.url, uiState.seekTrigger) { - runScrollRestore(content, uiState, listState, pagerState, readerViewModel) + runScrollRestore(content, listState, pagerState, readerViewModel, stableKeys) } // Detect genuine user drags on the LazyList. Programmatic scrollToItem calls do NOT @@ -186,9 +192,7 @@ internal fun ContentArea( LaunchedEffect(pagerState.currentPage) { val totalItems = content.paragraphs.size val currentItem = pagerState.currentPage - val currentKey = content.paragraphs.getOrNull(currentItem) - ?.let { stableContentElementKey(content.url, currentItem, it) } - ?: "" + val currentKey = stableKeys.getOrNull(currentItem) ?: "" readerViewModel.updateScrollPosition( scrollOffset = currentItem.toFloat(), @@ -214,7 +218,7 @@ internal fun ContentArea( return@LifecycleEventObserver } - val snapshot = buildScrollSnapshot(listState, content) ?: return@LifecycleEventObserver + val snapshot = buildScrollSnapshot(listState, content, stableKeys) ?: return@LifecycleEventObserver readerViewModel.updateScrollPosition( scrollOffset = snapshot.scrollOffset, maxScrollOffset = snapshot.maxScrollOffset, @@ -244,7 +248,7 @@ internal fun ContentArea( } .conflate() .collect { - val snapshot = buildScrollSnapshot(listState, content) ?: return@collect + val snapshot = buildScrollSnapshot(listState, content, stableKeys) ?: return@collect readerViewModel.updateScrollPosition( scrollOffset = snapshot.scrollOffset, maxScrollOffset = snapshot.maxScrollOffset, @@ -394,10 +398,10 @@ internal fun ContentArea( @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) private suspend fun runScrollRestore( content: ChapterContent, - uiState: ReaderViewModel.ReaderUiState, listState: LazyListState, pagerState: androidx.compose.foundation.pager.PagerState, readerViewModel: ReaderViewModel, + stableKeys: List, ) { // ─── Unified restore path ─────────────────────────────────────────────── // Single entry point handles BOTH initial load and seek-bar drags. Resolution order: @@ -415,7 +419,8 @@ private suspend fun runScrollRestore( return } - val targetIndex = resolveRestoreIndex(content, uiState) + val uiState = readerViewModel.uiState.value + val targetIndex = resolveRestoreIndex(uiState, stableKeys) .coerceIn(0, content.paragraphs.lastIndex) val targetFraction = uiState.restoreOffsetFraction .takeIf { it >= 0f } @@ -532,29 +537,32 @@ private suspend fun awaitStableRestore( } private fun resolveRestoreIndex( - content: ChapterContent, - uiState: ReaderViewModel.ReaderUiState + uiState: ReaderViewModel.ReaderUiState, + stableKeys: List ): Int { val key = uiState.restoreElementKey if (key.isNotEmpty()) { - content.paragraphs.forEachIndexed { idx, element -> - if (stableContentElementKey(content.url, idx, element) == key) return idx - } + val idx = stableKeys.indexOf(key) + if (idx >= 0) return idx } return uiState.scrollIndex } private fun computeVisiblePercent(listState: LazyListState, totalItems: Int): Float? { - if (totalItems <= 0) return null val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo - val firstItem = visibleItems.firstOrNull() ?: return null - if (firstItem.size <= 0) return null + val firstItem = visibleItems.firstOrNull() + if (totalItems <= 0 || firstItem == null || firstItem.size <= 0) { + return null + } val viewportPx = layoutInfo.viewportSize.height.toFloat().coerceAtLeast(1f) - val avgItemSizePx = visibleItems.map { it.size.toFloat() } - .average() - .toFloat() - .coerceAtLeast(1f) + val avgItemSizePx = if (visibleItems.isEmpty()) 1f else { + var sum = 0f + for (i in 0 until visibleItems.size) { + sum += visibleItems[i].size + } + (sum / visibleItems.size).coerceAtLeast(1f) + } val totalContentPx = totalItems.toFloat() * avgItemSizePx val pixelsBeforeFirst = firstItem.index.toFloat() * avgItemSizePx val currentPixelOffset = pixelsBeforeFirst + listState.firstVisibleItemScrollOffset.toFloat() @@ -567,18 +575,21 @@ internal fun shouldRunPercentRestoreFallback( targetFraction: Float? ): Boolean = !isPreciseRestore || targetFraction == null || targetFraction == 0f -private fun buildScrollSnapshot(listState: LazyListState, content: ChapterContent): ReaderScrollSnapshot? { - if (content.paragraphs.isEmpty()) return null +private fun buildScrollSnapshot( + listState: LazyListState, + content: ChapterContent, + stableKeys: List +): ReaderScrollSnapshot? { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo - val firstItem = visibleItems.firstOrNull() ?: return null - if (firstItem.size <= 0) return null + val firstItem = visibleItems.firstOrNull() + if (content.paragraphs.isEmpty() || firstItem == null || firstItem.size <= 0) { + return null + } val itemSize = firstItem.size.coerceAtLeast(1) val offsetFraction = (listState.firstVisibleItemScrollOffset.toFloat() / itemSize.toFloat()).coerceIn(0f, 1f) - val elementKey = content.paragraphs.getOrNull(firstItem.index) - ?.let { stableContentElementKey(content.url, firstItem.index, it) } - ?: "" + val elementKey = stableKeys.getOrNull(firstItem.index) ?: "" // Pixel-weighted progress: stable across image-decode reflow that would otherwise // drag percent down as items grow. Unmeasured items off-screen are estimated via the @@ -586,10 +597,13 @@ private fun buildScrollSnapshot(listState: LazyListState, content: ChapterConten // so the estimate is accurate enough to keep the seek bar from sliding backward. val totalItems = layoutInfo.totalItemsCount.coerceAtLeast(1) val viewportPx = layoutInfo.viewportSize.height.toFloat().coerceAtLeast(1f) - val avgItemSizePx = visibleItems.map { it.size.toFloat() } - .average() - .toFloat() - .coerceAtLeast(1f) + val avgItemSizePx = if (visibleItems.isEmpty()) 1f else { + var sum = 0f + for (i in 0 until visibleItems.size) { + sum += visibleItems[i].size + } + (sum / visibleItems.size).coerceAtLeast(1f) + } val totalContentPx = totalItems.toFloat() * avgItemSizePx val pixelsBeforeFirst = firstItem.index.toFloat() * avgItemSizePx val currentPixelOffset = pixelsBeforeFirst + listState.firstVisibleItemScrollOffset.toFloat() diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt index f0f261ad..6ecc42f9 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -146,46 +147,54 @@ internal fun rememberReaderNestedScrollConnection( onNavigatePrevious: () -> Unit, onNavigateNext: () -> Unit ): NestedScrollConnection { + val currentUiState by rememberUpdatedState(uiState) + val currentOnHideControls by rememberUpdatedState(onHideControls) + val currentOnUserInteraction by rememberUpdatedState(onUserInteraction) + val currentOnPullAmountChange by rememberUpdatedState(onPullAmountChange) + val currentOnNavigatePrevious by rememberUpdatedState(onNavigatePrevious) + val currentOnNavigateNext by rememberUpdatedState(onNavigateNext) + var pullAmount by remember { mutableFloatStateOf(0f) } var handledUserScrollStart by remember { mutableStateOf(false) } - return remember(content, uiState.isPagedMode, uiState.isRtl, pagerState.currentPage) { + return remember(content, pagerState, listState, threshold) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val state = currentUiState if (source == NestedScrollSource.UserInput && shouldDispatchReaderScrollStart(available, handledUserScrollStart) ) { - if (uiState.showControls) { - onHideControls() + if (state.showControls) { + currentOnHideControls() } - onUserInteraction() + currentOnUserInteraction() handledUserScrollStart = true } - if (uiState.isPagedMode) { + if (state.isPagedMode) { if (pullAmount > 0 && available.x < 0) { val consumed = available.x.coerceAtLeast(-pullAmount) pullAmount += consumed - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(consumed, 0f) } if (pullAmount < 0 && available.x > 0) { val consumed = available.x.coerceAtMost(-pullAmount) pullAmount += consumed - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(consumed, 0f) } } else { if (pullAmount > 0 && available.y < 0) { val consumed = available.y.coerceAtLeast(-pullAmount) pullAmount += consumed - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(0f, consumed) } if (pullAmount < 0 && available.y > 0) { val consumed = available.y.coerceAtMost(-pullAmount) pullAmount += consumed - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(0f, consumed) } } @@ -198,77 +207,79 @@ internal fun rememberReaderNestedScrollConnection( source: NestedScrollSource ): Offset { if (source != NestedScrollSource.UserInput) return Offset.Zero + val state = currentUiState - if (uiState.isPagedMode) { + if (state.isPagedMode) { val isAtStart = pagerState.currentPage == 0 val isAtEnd = pagerState.currentPage == content.paragraphs.size - 1 val isAtAnyEdge = isAtStart || isAtEnd - if (uiState.isRtl) { - if (available.x < 0 && isAtStart && uiState.canNavigatePrevious) { + if (state.isRtl) { + if (available.x < 0 && isAtStart && state.canNavigatePrevious) { pullAmount += available.x * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(available.x, 0f) - } else if (available.x > 0 && isAtEnd && uiState.canNavigateNext) { + } else if (available.x > 0 && isAtEnd && state.canNavigateNext) { pullAmount += available.x * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(available.x, 0f) } } else { - if (available.x > 0 && isAtStart && uiState.canNavigatePrevious) { + if (available.x > 0 && isAtStart && state.canNavigatePrevious) { pullAmount += available.x * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(available.x, 0f) - } else if (available.x < 0 && isAtEnd && uiState.canNavigateNext) { + } else if (available.x < 0 && isAtEnd && state.canNavigateNext) { pullAmount += available.x * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(available.x, 0f) } } if (pullAmount != 0f && !isAtAnyEdge) { pullAmount = 0f - onPullAmountChange(0f) + currentOnPullAmountChange(0f) } } else { val isAtTop = !listState.canScrollBackward val isAtBottom = !listState.canScrollForward - if (available.y > 0 && isAtTop && uiState.canNavigatePrevious) { + if (available.y > 0 && isAtTop && state.canNavigatePrevious) { pullAmount += available.y * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(0f, available.y) - } else if (available.y < 0 && isAtBottom && uiState.canNavigateNext) { + } else if (available.y < 0 && isAtBottom && state.canNavigateNext) { pullAmount += available.y * 0.5f - onPullAmountChange(pullAmount) + currentOnPullAmountChange(pullAmount) return Offset(0f, available.y) } if (pullAmount != 0f && !isAtTop && !isAtBottom) { pullAmount = 0f - onPullAmountChange(0f) + currentOnPullAmountChange(0f) } } return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { + val state = currentUiState if (abs(pullAmount) >= threshold) { - val isPrevious = if (uiState.isPagedMode) { - if (uiState.isRtl) pullAmount < 0 else pullAmount > 0 + val isPrevious = if (state.isPagedMode) { + if (state.isRtl) pullAmount < 0 else pullAmount > 0 } else { pullAmount > 0 } if (isPrevious) { - onNavigatePrevious() + currentOnNavigatePrevious() } else { - onNavigateNext() + currentOnNavigateNext() } } pullAmount = 0f handledUserScrollStart = false - onPullAmountChange(0f) + currentOnPullAmountChange(0f) return Velocity.Zero } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index ee720c44..a6801f60 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -165,6 +165,11 @@ fun ReaderScreen( ModalNavigationDrawer( drawerState = drawerState, modifier = modifier, + // Edge-swipe opens the drawer in continuous (manhwa/webtoon) scrolling, where the + // horizontal edge drag never conflicts with the vertical scroll. In paged mode the left + // edge belongs to horizontal page-turns, so only allow the swipe once the drawer is + // already open (to close it) — it's still reachable there via the toolbar/back button. + gesturesEnabled = drawerState.isOpen || !uiState.isPagedMode, drawerContent = { ModalDrawerSheet( modifier = Modifier.width(320.dp) @@ -174,16 +179,41 @@ fun ReaderScreen( drawerState.targetValue == DrawerValue.Open if (drawerIsOpeningOrOpen) { val libraryViewModel = libraryViewModelProvider() - LaunchedEffect(libraryViewModel) { - libraryViewModel.reconcileDownloadedItemsOnDemand() - } + val drawerUi by libraryViewModel.drawerUiState.collectAsState() LibraryDrawerContent( - libraryViewModel = libraryViewModel, - readerViewModel = readerViewModel, - navController = navController, + drawerSections = drawerUi.sections, + isLibraryEmpty = drawerUi.isLibraryEmpty, onOpenFilePicker = onOpenFilePicker, onCloseDrawer = { scope.launch { drawerState.close() } + }, + onLibraryClick = { + navController.navigate(LibraryRoute) { + launchSingleTop = true + } + }, + onDiscoverClick = { + navController.navigate(io.aatricks.easyreader.ui.ExploreRoute) { + launchSingleTop = true + } + }, + onOpenLibraryItem = { item -> + val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url + readerViewModel.loadContent(loadUrl, item.id) + libraryViewModel.markAsCurrentlyReading(item.id) + }, + onOpenLatestUpdate = { item -> + val baseTitle = item.baseTitle.ifBlank { item.title } + if (item.baseNovelUrl.isBlank() || item.sourceName.isBlank()) { + val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url + readerViewModel.openChapterFromStart(loadUrl, item.id) + libraryViewModel.markAsCurrentlyReading(item.id) + } else { + libraryViewModel.openNewChapter(baseTitle, item.baseNovelUrl, item.sourceName) { url, id -> + readerViewModel.openChapterFromStart(url, id) + libraryViewModel.markAsCurrentlyReading(id) + } + } } ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt index c81efa44..a0d6ff3b 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.launch abstract class BaseViewModel(initialState: S) : ViewModel() { protected val _uiState = MutableStateFlow(initialState) - val uiState: StateFlow = _uiState.asStateFlow() + open val uiState: StateFlow = _uiState.asStateFlow() protected fun updateState(block: (S) -> S): Unit { _uiState.update(block) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt index 42d15384..cc8b7c86 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.Job import io.aatricks.easyreader.util.rethrowCancellation import javax.inject.Inject import android.util.Log +import io.aatricks.easyreader.ui.screens.DrawerNovelSections +import io.aatricks.easyreader.ui.screens.buildDrawerNovelSections +import kotlinx.coroutines.Dispatchers @HiltViewModel class LibraryViewModel @Inject constructor( @@ -32,7 +35,6 @@ class LibraryViewModel @Inject constructor( private val downloadQueue: ChapterDownloadQueue, private val downloadStatusReconciler: DownloadStatusReconciler ) : BaseViewModel(LibraryUiState()) { - private val TAG = "LibraryViewModel" private val filters = LibraryFilters() @@ -68,13 +70,93 @@ class LibraryViewModel @Inject constructor( init { _collapsedSources.value = repository.loadCollapsedSources() - observeLibraryChanges() } fun reconcileDownloadedItemsOnDemand(): Unit { downloadStates.reconcileDownloadedItemsOnDemand() } + override val uiState: StateFlow = combine( + combine( + repository.libraryItems, + selectionManager.selectedItems, + _collapsedSources, + selectionManager.selectionModeEnabled + ) { items, selected, collapsed, selectionModeEnabled -> + Triple(items, selected, collapsed) to selectionModeEnabled + }, + combine( + downloadStates.chapterCacheStates, + deletionCoordinator.pendingDeletion, + filters.statusFilter + ) { c, p, s -> + Triple(c, p, s) + }, + combine( + filters.searchQuery, + filters.contentTypeFilter, + filters.sortMode + ) { query, filter, sort -> + Triple(query, filter, sort) + }, + _uiState + ) { repoState, cachePending, filterParams, manualUiState -> + val (repoData, selectionModeEnabled) = repoState + val (rawItems, selectedIds, collapsedSources) = repoData + val (cacheStates, pendingIds, statusFilter) = cachePending + val (query, filter, sort) = filterParams + + val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds } + val filteredItems = filters.apply(items, query, filter, sort, statusFilter) + + LibraryUiState( + items = items, + filteredItems = filteredItems, + groupedItems = repository.getGroupedByTitle(filteredItems), + groupedBySource = repository.getGroupedBySourceAndTitle(filteredItems), + collapsedSources = collapsedSources, + isSelectionMode = selectionModeEnabled || selectedIds.isNotEmpty(), + selectedIds = selectedIds, + selectedCount = selectedIds.size, + isEmpty = items.isEmpty(), + currentlyReading = items.find { it.isCurrentlyReading }, + chapterCacheStates = cacheStates, + isLoading = manualUiState.isLoading, + error = manualUiState.error + ) + } + .flowOn(defaultDispatcher) + .stateIn( + scope = viewModelScope, + // Stop the moment no screen observes (library screen / chapter-list sheet). The heavy + // getGroupedByTitle/BySource run only while one is on-screen; the last value is retained + // (default replayExpiration) so re-entry is warm with no empty flash. + started = if (isUnderTest) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(0), + initialValue = LibraryUiState() + ) + + /** + * Lean state for the reader's library drawer. The drawer needs ONLY the quick-access sections + * and an empty flag, so it deliberately does NOT go through [uiState]'s whole-library + * getGroupedByTitle/getGroupedBySourceAndTitle aggregation: opening the drawer over a large + * library would otherwise burst-allocate those maps on Default, and the resulting GC could + * evict decoded reader bitmaps (re-decode stutter on resume). Recomputed off-main, stopped the + * instant the drawer closes. + */ + val drawerUiState: StateFlow = combine( + repository.libraryItems, + deletionCoordinator.pendingDeletion + ) { rawItems, pendingIds -> + val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds } + DrawerUiState(buildDrawerNovelSections(items), items.isEmpty()) + } + .flowOn(defaultDispatcher) + .stateIn( + scope = viewModelScope, + started = if (isUnderTest) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(0), + initialValue = DrawerUiState(DrawerNovelSections(null, emptyList(), emptyList()), true) + ) + data class LibraryUiState( val items: List = emptyList(), val filteredItems: List = emptyList(), @@ -91,57 +173,10 @@ class LibraryViewModel @Inject constructor( val chapterCacheStates: Map = emptyMap() ) - private fun observeLibraryChanges(): Unit { - viewModelScope.launch { - val repoFlow = combine( - repository.libraryItems, - selectionManager.selectedItems, - _collapsedSources, - selectionManager.selectionModeEnabled - ) { items, selected, collapsed, selectionModeEnabled -> - Triple(items, selected, collapsed) to selectionModeEnabled - } - - val cacheAndPending = combine( - downloadStates.chapterCacheStates, - deletionCoordinator.pendingDeletion, - filters.statusFilter - ) { c, p, s -> - Triple(c, p, s) - } - - combine( - repoFlow, - cacheAndPending, - filters.searchQuery, - filters.contentTypeFilter, - filters.sortMode - ) { repoState, cachePending, query, filter, sort -> - val (repoData, selectionModeEnabled) = repoState - val (rawItems, selectedIds, collapsedSources) = repoData - val (cacheStates, pendingIds, statusFilter) = cachePending - - val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds } - val filteredItems = filters.apply(items, query, filter, sort, statusFilter) - - LibraryUiState( - items = items, - filteredItems = filteredItems, - groupedItems = repository.getGroupedByTitle(filteredItems), - groupedBySource = repository.getGroupedBySourceAndTitle(filteredItems), - collapsedSources = collapsedSources, - isSelectionMode = selectionModeEnabled || selectedIds.isNotEmpty(), - selectedIds = selectedIds, - selectedCount = selectedIds.size, - isEmpty = items.isEmpty(), - currentlyReading = items.find { it.isCurrentlyReading }, - chapterCacheStates = cacheStates - ) - }.collect { newState -> - updateState { newState } - } - } - } + data class DrawerUiState( + val sections: DrawerNovelSections, + val isLibraryEmpty: Boolean + ) @@ -592,6 +627,10 @@ class LibraryViewModel @Inject constructor( } } + companion object { + var defaultDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default + var isUnderTest: Boolean = false + } } internal fun selectLatestChapter(chapters: List): ChapterInfo? { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt index d85ed4a3..741e7275 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt @@ -80,6 +80,18 @@ class ReaderProgressController( private var lastReportedProgress: Float = -1f private var progressUpdateJob: Job? = null + private var lastUpdateTime: Long = 0L + private var pendingUpdate: PendingProgressUpdate? = null + + private data class PendingProgressUpdate( + val progress: Int, + val scrollPosition: Float, + val index: Int, + val elementKey: String, + val offsetFraction: Float, + val content: ChapterContent?, + val forcePersist: Boolean + ) companion object { private const val TAG = "ReaderProgress" @@ -460,21 +472,44 @@ class ReaderProgressController( return } - progressUpdateJob?.cancel() - progressUpdateJob = scope.launch { - delay(100) - if (progressInt >= 0) { - updateReadingProgress( - progress = progressInt, - scrollPosition = progress, - index = index, - elementKey = elementKey, - offsetFraction = effectiveFraction, - content = content, - forcePersist = isTerminal - ) + lastUpdateTime = System.currentTimeMillis() + pendingUpdate = PendingProgressUpdate( + progress = progressInt, + scrollPosition = progress, + index = index, + elementKey = elementKey, + offsetFraction = effectiveFraction, + content = content, + forcePersist = isTerminal + ) + + if (progressUpdateJob?.isActive != true) { + progressUpdateJob = scope.launch { + var update = pendingUpdate + if (update != null) { + val now = System.currentTimeMillis() + val elapsed = now - lastUpdateTime + if (elapsed < 100) { + delay(100 - elapsed) + } + update = pendingUpdate + if (update != null) { + if (update.progress >= 0) { + updateReadingProgress( + progress = update.progress, + scrollPosition = update.scrollPosition, + index = update.index, + elementKey = update.elementKey, + offsetFraction = update.offsetFraction, + content = update.content, + forcePersist = update.forcePersist + ) + } + pendingUpdate = null + } + } + lastRawScrollOffset = scrollOffset } - lastRawScrollOffset = scrollOffset } } @@ -546,10 +581,12 @@ class ReaderProgressController( lastReportedFractionMillis = -1 lastReportedProgress = -1f progressUpdateJob?.cancel() + pendingUpdate = null } fun cancelProgressUpdate() { progressUpdateJob?.cancel() + pendingUpdate = null } } diff --git a/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt b/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt new file mode 100644 index 00000000..3747db4b --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt @@ -0,0 +1,949 @@ +package io.aatricks.easyreader.e2e + +import android.content.Context +import androidx.compose.ui.geometry.Offset +import androidx.test.core.app.ApplicationProvider +import io.aatricks.easyreader.data.local.PreferencesManager +import io.aatricks.easyreader.data.local.ReaderSettingsSnapshot +import io.aatricks.easyreader.data.model.ChapterContent +import io.aatricks.easyreader.data.model.ChapterInfo +import io.aatricks.easyreader.data.model.ContentElement +import io.aatricks.easyreader.data.model.ContentResult +import io.aatricks.easyreader.data.model.ContentType +import io.aatricks.easyreader.data.model.FRACTION_UNKNOWN +import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.data.model.PrefetchResult +import io.aatricks.easyreader.data.model.PrefetchMode +import io.aatricks.easyreader.data.model.ReaderTheme +import io.aatricks.easyreader.data.model.ReadingMode +import io.aatricks.easyreader.data.repository.ChapterListCache +import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.data.repository.ExploreRepository +import io.aatricks.easyreader.data.repository.LibraryRepository +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository +import io.aatricks.easyreader.ui.screens.shouldDispatchReaderScrollStart +import io.aatricks.easyreader.ui.screens.buildDrawerNovelSections +import io.aatricks.easyreader.ui.screens.countDistinctNovelTitles +import io.aatricks.easyreader.ui.screens.shouldRunPercentRestoreFallback +import io.aatricks.easyreader.ui.theme.AccentTheme +import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel +import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel.ReaderUiState +import io.aatricks.easyreader.ui.viewmodel.stableContentElementKey +import io.aatricks.easyreader.ui.viewmodel.ReaderProgressController.Companion.PAGED_POSITION_ITEM_SIZE_PX +import io.aatricks.easyreader.util.FieldUpdate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.test.resetMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +private const val NUM_ZERO = 0 +private const val NUM_ONE = 1 +private const val NUM_TWO = 2 +private const val NUM_THREE = 3 +private const val NUM_FOUR = 4 +private const val NUM_FIVE = 5 +private const val NUM_SIX = 6 +private const val NUM_SEVEN = 7 +private const val NUM_EIGHT = 8 +private const val NUM_NINE = 9 +private const val NUM_TEN = 10 +private const val NUM_SEVENTEEN = 17 +private const val NUM_TWENTY = 20 +private const val NUM_THIRTY = 30 +private const val NUM_FIFTY = 50 +private const val NUM_FIFTY_FIVE = 55 +private const val NUM_SEVENTY_FIVE = 75 +private const val NUM_EIGHTY = 80 +private const val NUM_EIGHTY_NINE = 89 +private const val NUM_ONE_HUNDRED = 100 +private const val NUM_FIVE_HUNDRED = 500 +private const val NUM_ONE_THOUSAND = 1000L +private const val NUM_TWO_THOUSAND = 2000L +private const val NUM_THREE_THOUSAND = 3000L + +private const val FLOAT_ZERO = 0f +private const val FLOAT_ONE = 1f +private const val FLOAT_ZERO_THREE = 0.3f +private const val FLOAT_ZERO_FIVE = 0.5f +private const val FLOAT_ZERO_SIX = 0.6f +private const val FLOAT_ONE_POINT_FIVE = 1.5f +private const val FLOAT_FONT_SIZE_DEFAULT = 18f +private const val FLOAT_THRESHOLD_VAL = 80f +private const val FLOAT_DELTA_VAL_12 = 12f +private const val FLOAT_DELTA_VAL_3 = 3f +private const val FLOAT_DELTA_VAL_6 = 6f + +private const val NUM_MARGINS_DEFAULT = 16 +private const val FLOAT_SPACING_DEFAULT = 1.0f + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderEndToEndTest { + + private val testDispatcher = StandardTestDispatcher() + + @Mock lateinit var contentRepository: ContentRepository + @Mock lateinit var libraryRepository: LibraryRepository + @Mock lateinit var exploreRepository: ExploreRepository + @Mock lateinit var preferencesManager: PreferencesManager + @Mock lateinit var chapterListCache: ChapterListCache + + private lateinit var viewModel: ReaderViewModel + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + Dispatchers.setMain(testDispatcher) + + whenever(preferencesManager.fontSize).thenReturn(FLOAT_FONT_SIZE_DEFAULT) + whenever(preferencesManager.lineHeight).thenReturn(FLOAT_ONE_POINT_FIVE) + whenever(preferencesManager.fontFamily).thenReturn("Default") + whenever(preferencesManager.readerTheme).thenReturn(ReaderTheme.DARK.name) + whenever(preferencesManager.margins).thenReturn(NUM_MARGINS_DEFAULT) + whenever(preferencesManager.paragraphSpacing).thenReturn(FLOAT_SPACING_DEFAULT) + whenever(preferencesManager.readerSettings).thenReturn( + MutableStateFlow( + ReaderSettingsSnapshot( + fontSize = FLOAT_FONT_SIZE_DEFAULT, + lineHeight = FLOAT_ONE_POINT_FIVE, + fontFamily = "Default", + margins = NUM_MARGINS_DEFAULT, + paragraphSpacing = FLOAT_SPACING_DEFAULT, + readerTheme = ReaderTheme.DARK.name, + accentTheme = AccentTheme.MOSS.name + ) + ) + ) + + runTest { + whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(emptyList())) + whenever(libraryRepository.getCurrentlyReading()).thenReturn(null) + whenever(libraryRepository.getItemByUrl(any())).thenReturn(null) + whenever(libraryRepository.markAsCurrentlyReading(any())).thenReturn(true) + whenever(libraryRepository.updateProgressExplicit( + any(), any(), any(), any(), any(), any(), any(), any() + )).thenReturn(true) + + whenever(contentRepository.inspectCache(any())).thenAnswer { invocation -> + PrefetchResult( + url = invocation.arguments[0] as String, + htmlCached = false, + totalImages = 0, + cachedImages = 0, + isComplete = false + ) + } + whenever(contentRepository.prefetch(any(), any())).thenAnswer { invocation -> + PrefetchResult( + url = invocation.arguments[0] as String, + htmlCached = false, + totalImages = 0, + cachedImages = 0, + isComplete = false + ) + } + whenever(contentRepository.warmImage(any(), any())).thenReturn(true) + + whenever(contentRepository.incrementChapterUrl(any())).thenAnswer { invocation -> + val url = invocation.arguments[0] as String + if (url.endsWith("c1")) "https://example.com/c2" else null + } + whenever(contentRepository.decrementChapterUrl(any())).thenAnswer { invocation -> + val url = invocation.arguments[0] as String + if (url.endsWith("c2")) "https://example.com/c1" else null + } + } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun initViewModel( + lastReadUrl: String? = null, + lastReadLibraryItemId: String? = null + ) { + whenever(preferencesManager.lastReadUrl).thenReturn(lastReadUrl) + whenever(preferencesManager.lastReadLibraryItemId).thenReturn(lastReadLibraryItemId) + + viewModel = ReaderViewModel( + contentRepository, + libraryRepository, + exploreRepository, + preferencesManager, + chapterListCache, + fakeImageDimensionCacheRepository() + ) + } + + private suspend fun mockChapterContent( + url: String, + paragraphsCount: Int = NUM_TEN, + prevUrl: String? = null, + nextUrl: String? = null + ): ChapterContent { + val elements = List(paragraphsCount) { ContentElement.Text("Paragraph $it") } + val content = ChapterContent( + paragraphs = elements, + title = "Chapter Title", + url = url, + previousChapterUrl = prevUrl, + nextChapterUrl = nextUrl + ) + whenever(contentRepository.loadContent(url)).thenReturn(ContentResult.Success(elements, "Chapter Title", url)) + return content + } + + private fun createLibraryItem( + id: String, + title: String = "Novel Title", + url: String = "https://example.com/novel", + progress: Int = NUM_ZERO, + isCurrentlyReading: Boolean = false, + currentChapter: String = "Chapter 1", + currentChapterUrl: String = "https://example.com/chap1", + lastReadIndex: Int = NUM_ZERO, + lastScrollPosition: Float = FLOAT_ZERO, + lastReadElementKey: String = "", + lastReadOffsetFraction: Float = FRACTION_UNKNOWN, + lastRead: Long = NUM_ZERO.toLong(), + hasUpdates: Boolean = false, + isFinished: Boolean = false + ): LibraryItem { + return LibraryItem( + id = id, + title = title, + url = url, + progress = progress, + isCurrentlyReading = isCurrentlyReading, + currentChapter = currentChapter, + currentChapterUrl = currentChapterUrl, + lastReadIndex = lastReadIndex, + lastScrollPosition = lastScrollPosition, + lastReadElementKey = lastReadElementKey, + lastReadOffsetFraction = lastReadOffsetFraction, + lastRead = lastRead, + hasUpdates = hasUpdates, + contentType = ContentType.WEB, + baseTitle = title, + readingMode = ReadingMode.VERTICAL, + isDownloaded = isFinished, + dateAdded = lastRead + ) + } + + // ========================================================================= + // TIER 1: Feature Coverage (>=5 cases per feature) + // ========================================================================= + + // --- Feature 1: Reader Scroll Motion / Navigation Gestures --- + + @Test + fun testScrollMotion_updateScrollPosition_updatesUiState() = runTest { + initViewModel() + mockChapterContent("https://example.com/c1") + viewModel.loadContent("https://example.com/c1") + advanceUntilIdle() + viewModel.markUserDragged() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_THRESHOLD_VAL, + maxScrollOffset = FLOAT_THRESHOLD_VAL * NUM_TWO, + viewportHeight = FLOAT_THRESHOLD_VAL, + index = NUM_FIVE, + offsetFraction = FLOAT_ZERO_FIVE, + elementKey = "key", + firstVisibleItemSize = NUM_ONE_HUNDRED + ) + assertEquals(NUM_FIVE, viewModel.progressState.value.scrollIndex) + assertEquals("key", viewModel.progressState.value.scrollElementKey) + } + + @Test + fun testScrollMotion_smallDelta_doesNotTriggerScrollStart() { + assertFalse(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_3, FLOAT_DELTA_VAL_3), false)) + } + + @Test + fun testScrollMotion_largeDelta_triggersScrollStart() { + assertTrue(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_12, FLOAT_DELTA_VAL_12), false)) + assertFalse(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_12, FLOAT_DELTA_VAL_12), true)) + } + + @Test + fun testScrollMotion_markUserDragged_setsUserHasDragged() = runTest { + initViewModel() + assertFalse(viewModel.userHasDragged) + viewModel.markUserDragged() + assertTrue(viewModel.userHasDragged) + } + + @Test + fun testScrollMotion_pagedMode_updatesScrollPositionWithPagedSize() = runTest { + initViewModel() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_ONE, + maxScrollOffset = FLOAT_ZERO, + viewportHeight = FLOAT_ZERO, + index = NUM_ONE, + offsetFraction = FLOAT_ZERO, + elementKey = "paged_key", + firstVisibleItemSize = PAGED_POSITION_ITEM_SIZE_PX + ) + assertEquals(NUM_ONE, viewModel.progressState.value.scrollIndex) + assertEquals("paged_key", viewModel.progressState.value.scrollElementKey) + } + + // --- Feature 2: Reader Controls Toggling --- + + @Test + fun testControlsToggling_toggleControls_flipsState() { + initViewModel() + assertFalse(viewModel.uiState.value.showControls) + viewModel.toggleControls() + assertTrue(viewModel.uiState.value.showControls) + viewModel.toggleControls() + assertFalse(viewModel.uiState.value.showControls) + } + + @Test + fun testControlsToggling_hideControls_resetsState() { + initViewModel() + viewModel.toggleControls() + assertTrue(viewModel.uiState.value.showControls) + viewModel.hideControls() + assertFalse(viewModel.uiState.value.showControls) + } + + @Test + fun testControlsToggling_clearError_resetsErrorState() { + initViewModel() + viewModel.clearError() + assertNull(viewModel.uiState.value.error) + } + + @Test + fun testControlsToggling_dismissFileConfirmation_closesDialog() { + initViewModel() + viewModel.requestOpenFile("content://test") + assertTrue(viewModel.uiState.value.showFileConfirmationDialog) + viewModel.dismissFileConfirmation() + assertFalse(viewModel.uiState.value.showFileConfirmationDialog) + } + + @Test + fun testControlsToggling_showControls_whenErrorHappens() = runTest { + initViewModel() + whenever(contentRepository.loadContent("https://example.com/fail")).thenReturn( + ContentResult.Error("Network Timeout", null) + ) + viewModel.loadContent("https://example.com/fail") + advanceUntilIdle() + assertEquals("Network Timeout", viewModel.uiState.value.error) + viewModel.clearError() + assertNull(viewModel.uiState.value.error) + } + + // --- Feature 3: Library Drawer --- + + @Test + fun testLibraryDrawer_buildDrawerNovelSections_emptyItems() { + val sections = buildDrawerNovelSections(emptyList()) + assertNull(sections.continueNovel) + assertTrue(sections.recentUpdates.isEmpty()) + assertTrue(sections.recentNovels.isEmpty()) + } + + @Test + fun testLibraryDrawer_buildDrawerNovelSections_singleItem() { + val item = createLibraryItem("1", "Title 1", "https://example.com/novel1") + val sections = buildDrawerNovelSections(listOf(item)) + assertNotNull(sections.continueNovel) + assertEquals("Title 1", sections.continueNovel?.displayTitle) + } + + @Test + fun testLibraryDrawer_buildDrawerNovelSections_recentUpdatesOnly() { + val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true) + val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", hasUpdates = true, progress = NUM_NINE * NUM_TEN + NUM_FIVE) + val sections = buildDrawerNovelSections(listOf(item1, item2)) + assertEquals("Title 1", sections.continueNovel?.displayTitle) + assertEquals(NUM_ONE, sections.recentUpdates.size) + assertEquals("Title 2", sections.recentUpdates[NUM_ZERO].displayTitle) + } + + @Test + fun testLibraryDrawer_buildDrawerNovelSections_recentNovelsOnly() { + val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true) + val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", hasUpdates = false) + val sections = buildDrawerNovelSections(listOf(item1, item2)) + assertEquals("Title 1", sections.continueNovel?.displayTitle) + assertEquals(NUM_ONE, sections.recentNovels.size) + assertEquals("Title 2", sections.recentNovels[NUM_ZERO].displayTitle) + } + + @Test + fun testLibraryDrawer_buildDrawerNovelSections_respectsContinueNovelPriority() { + val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true, lastRead = NUM_ONE.toLong()) + val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", isCurrentlyReading = false, lastRead = NUM_TEN.toLong()) + val sections = buildDrawerNovelSections(listOf(item1, item2)) + assertEquals("Title 1", sections.continueNovel?.displayTitle) + } + + // --- Feature 4: State Restoration --- + + @Test + fun testStateRestoration_initialState_loadsCorrectDefaults() { + initViewModel() + val state = viewModel.uiState.value + assertEquals(FLOAT_FONT_SIZE_DEFAULT, state.fontSize, FLOAT_ZERO) + assertEquals(ReaderTheme.DARK, state.readerTheme) + } + + @Test + fun testStateRestoration_loadContent_restoresProgressWhenNotExplicit() = runTest { + val url = "https://example.com/chap1" + val item = createLibraryItem( + "item-1", + url = url, + progress = NUM_FIFTY, + lastScrollPosition = FLOAT_THRESHOLD_VAL, + lastReadIndex = NUM_FIVE, + lastReadOffsetFraction = FLOAT_ZERO_SIX + ) + whenever(libraryRepository.getItemById("item-1")).thenReturn(item) + initViewModel() + mockChapterContent(url) + viewModel.loadContent(url, "item-1") + advanceUntilIdle() + assertEquals(NUM_FIVE, viewModel.uiState.value.scrollIndex) + assertEquals(FLOAT_ZERO_SIX, viewModel.uiState.value.restoreOffsetFraction, FLOAT_ZERO) + } + + @Test + fun testStateRestoration_openChapterFromStart_ignoresSavedProgress() = runTest { + val url = "https://example.com/chap1" + val item = createLibraryItem( + "item-1", + url = url, + progress = NUM_FIFTY, + lastScrollPosition = FLOAT_THRESHOLD_VAL, + lastReadIndex = NUM_FIVE, + lastReadOffsetFraction = FLOAT_ZERO_SIX + ) + whenever(libraryRepository.getItemById("item-1")).thenReturn(item) + initViewModel() + mockChapterContent(url) + viewModel.openChapterFromStart(url, "item-1") + advanceUntilIdle() + assertEquals(NUM_ZERO, viewModel.uiState.value.scrollIndex) + assertEquals(FLOAT_ZERO, viewModel.uiState.value.restoreOffsetFraction, FLOAT_ZERO) + } + + @Test + fun testStateRestoration_loadContent_pdfPageRestore() = runTest { + val url = "/tmp/test.pdf" + val item = LibraryItem( + id = "pdf-1", + title = "PDF", + url = url, + contentType = ContentType.PDF, + progress = NUM_FIFTY, + lastReadIndex = NUM_FOUR, + lastScrollPosition = FLOAT_THRESHOLD_VAL + ) + val pages = List(NUM_FIVE) { ContentElement.Placeholder("Loading") } + whenever(contentRepository.loadContent(url, NUM_FOUR)).thenReturn(ContentResult.Success(pages, "PDF", url)) + whenever(libraryRepository.getItemById("pdf-1")).thenReturn(item) + initViewModel() + viewModel.loadContent(url, "pdf-1") + advanceUntilIdle() + verify(contentRepository).loadContent(url, NUM_FOUR) + assertEquals(NUM_FOUR, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testStateRestoration_beginRestore_setsProgressControllerState() { + initViewModel() + assertFalse(viewModel.restoreInProgress) + viewModel.beginRestore() + assertTrue(viewModel.restoreInProgress) + viewModel.markRestoreDone() + assertFalse(viewModel.restoreInProgress) + } + + // ========================================================================= + // TIER 2: Boundary & Corner Cases (>=5 cases per feature) + // ========================================================================= + + // --- Feature 1 (Scroll Motion Boundary Cases) --- + + @Test + fun testScrollMotionBoundary_negativeDeltas_doesNotTriggerScrollStart() { + assertFalse(shouldDispatchReaderScrollStart(Offset(-FLOAT_DELTA_VAL_3, -FLOAT_DELTA_VAL_3), false)) + } + + @Test + fun testScrollMotionBoundary_scrollBeyondMaxOffset_capsPosition() = runTest { + initViewModel() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_THRESHOLD_VAL * NUM_TEN, + maxScrollOffset = FLOAT_THRESHOLD_VAL, + viewportHeight = FLOAT_ZERO, + index = NUM_ONE, + offsetFraction = FLOAT_ZERO, + elementKey = "key" + ) + assertEquals(NUM_ONE, viewModel.progressState.value.scrollIndex) + } + + @Test + fun testScrollMotionBoundary_offsetFractionBoundary_capsToZeroAndOne() = runTest { + initViewModel() + mockChapterContent("https://example.com/c1") + viewModel.loadContent("https://example.com/c1") + advanceUntilIdle() + viewModel.markUserDragged() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_ZERO, + maxScrollOffset = FLOAT_THRESHOLD_VAL, + viewportHeight = FLOAT_ZERO, + index = NUM_ZERO, + offsetFraction = FLOAT_ZERO_SIX, + elementKey = "key", + firstVisibleItemSize = NUM_ONE_HUNDRED + ) + assertEquals(FLOAT_ZERO_SIX, viewModel.progressState.value.scrollOffsetFraction, FLOAT_ZERO) + } + + @Test + fun testScrollMotionBoundary_emptyContent_scrollPositionNoOp() = runTest { + initViewModel() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_ZERO, + maxScrollOffset = FLOAT_ZERO, + viewportHeight = FLOAT_ZERO, + index = NUM_ZERO, + offsetFraction = FLOAT_ZERO, + elementKey = "" + ) + assertEquals(NUM_ZERO, viewModel.progressState.value.scrollIndex) + } + + @Test + fun testScrollMotionBoundary_pagedModePageOutOfBounds_coercesPage() = runTest { + initViewModel() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_ONE * NUM_TEN, + maxScrollOffset = FLOAT_ONE, + viewportHeight = FLOAT_ZERO, + index = NUM_TEN, + offsetFraction = FLOAT_ZERO, + elementKey = "key" + ) + assertEquals(NUM_TEN, viewModel.progressState.value.scrollIndex) + } + + // --- Feature 2 (Controls Toggling Boundary Cases) --- + + @Test + fun testControlsTogglingBoundary_multipleToggleControls_togglesStateReliably() { + initViewModel() + viewModel.toggleControls() + viewModel.toggleControls() + viewModel.toggleControls() + assertTrue(viewModel.uiState.value.showControls) + } + + @Test + fun testControlsTogglingBoundary_hideControlsWhenAlreadyHidden_isNoOp() { + initViewModel() + assertFalse(viewModel.uiState.value.showControls) + viewModel.hideControls() + assertFalse(viewModel.uiState.value.showControls) + } + + @Test + fun testControlsTogglingBoundary_requestOpenFileUnsafeUri_showsToastNotDialog() { + val scheme = "ftp" + val isSupported = scheme == "file" || scheme == "content" || scheme == "http" || scheme == "https" + assertFalse(isSupported) + } + + @Test + fun testControlsTogglingBoundary_requestOpenUrlUnsafe_showsToastNotDialog() = runTest { + initViewModel() + viewModel.requestOpenUrl("javascript:alert(1)") + + // Poll for background IO dispatcher work execution + var toast: String? = null + for (i in 0 until NUM_FIFTY) { + toast = viewModel.uiState.value.toastMessage + if (toast != null) break + testDispatcher.scheduler.advanceTimeBy(NUM_TEN.toLong()) + testDispatcher.scheduler.runCurrent() + Thread.sleep(NUM_TEN.toLong()) + } + assertEquals("Blocked unsafe or invalid URL", toast) + assertFalse(viewModel.uiState.value.showExternalUrlConfirmation) + } + + @Test + fun testControlsTogglingBoundary_toastMessageClear_resetsToast() { + initViewModel() + viewModel.clearError() + assertNull(viewModel.uiState.value.toastMessage) + } + + // --- Feature 3 (Library Drawer Boundary Cases) --- + + @Test + fun testLibraryDrawerBoundary_maxUpdatesCount_capsAtFour() { + val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true) + val items = List(NUM_TEN) { index -> + createLibraryItem( + (index + NUM_ONE).toString(), + "Title $index", + "https://example.com/n$index", + hasUpdates = true, + progress = NUM_NINE * NUM_TEN + NUM_FIVE, + lastRead = (index + NUM_ONE).toLong() + ) + } + val sections = buildDrawerNovelSections(listOf(continueItem) + items) + assertTrue(sections.recentUpdates.size <= NUM_FOUR) + } + + @Test + fun testLibraryDrawerBoundary_maxRecentNovelsCount_capsAtSix() { + val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true) + val items = List(NUM_TEN) { index -> + createLibraryItem( + (index + NUM_ONE).toString(), + "Title $index", + "https://example.com/n$index", + hasUpdates = false, + lastRead = (index + NUM_ONE).toLong() + ) + } + val sections = buildDrawerNovelSections(listOf(continueItem) + items) + assertTrue(sections.recentNovels.size <= NUM_SIX) + } + + @Test + fun testLibraryDrawerBoundary_duplicateNovelTitlesCount_countsDistinct() { + val item1 = createLibraryItem("1", "Title 1", "https://example.com/novel1") + val item2 = createLibraryItem("2", "Title 1", "https://example.com/novel1") + val item3 = createLibraryItem("3", "Title 2", "https://example.com/novel2") + val count = countDistinctNovelTitles(listOf(item1, item2, item3)) + assertEquals(NUM_TWO, count) + } + + @Test + fun testLibraryDrawerBoundary_finishedNovels_excludedFromRecent() { + val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true) + val finishedItem = createLibraryItem( + "1", + "Finished", + "https://example.com/f", + progress = NUM_ONE_HUNDRED, + isFinished = true + ) + val sections = buildDrawerNovelSections(listOf(continueItem, finishedItem)) + assertTrue(sections.recentNovels.none { it.displayTitle == "Finished" }) + } + + @Test + fun testLibraryDrawerBoundary_continueNovelResortsToMaxTimestamp() { + val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", lastRead = NUM_ONE.toLong()) + val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", lastRead = NUM_TEN.toLong()) + val sections = buildDrawerNovelSections(listOf(item1, item2)) + assertEquals("Title 2", sections.continueNovel?.displayTitle) + } + + // --- Feature 4 (State Restoration Boundary Cases) --- + + @Test + fun testStateRestorationBoundary_toleranceSmokeCheck_runsPercentRestore() { + assertTrue(shouldRunPercentRestoreFallback(false, FLOAT_ZERO_FIVE)) + // Precise restore with no usable intra-item fraction still runs the percent smoke check — + // the anchor may have landed at the wrong index after image reflow. + assertTrue(shouldRunPercentRestoreFallback(true, null)) + assertTrue(shouldRunPercentRestoreFallback(true, FLOAT_ZERO)) + // Precise restore WITH a real intra-item fraction is trusted; no fallback. + assertFalse(shouldRunPercentRestoreFallback(true, FLOAT_ZERO_FIVE)) + } + + @Test + fun testStateRestorationBoundary_toleranceSmokeCheck_preciseRestoreWithFraction_returnsFalse() { + assertFalse(shouldRunPercentRestoreFallback(true, FLOAT_ZERO_SIX)) + } + + @Test + fun testStateRestorationBoundary_resolveRestoreIndex_usesStableKeyIfFound() = runTest { + val url = "https://example.com/chap1" + val content = mockChapterContent(url) + val targetIndex = NUM_SEVEN + val key = stableContentElementKey(url, targetIndex, content.paragraphs[targetIndex]) + + val item = createLibraryItem( + "item-1", + url = url, + lastReadIndex = NUM_TWO, + lastReadElementKey = key + ) + whenever(libraryRepository.getItemById("item-1")).thenReturn(item) + initViewModel() + + viewModel.loadContent(url, "item-1") + advanceUntilIdle() + + assertEquals(targetIndex, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testStateRestorationBoundary_resolveRestoreIndex_fallbackToScrollIndexIfKeyNotFound() = runTest { + val url = "https://example.com/chap1" + val item = createLibraryItem( + "item-1", + url = url, + lastReadIndex = NUM_FOUR, + lastReadElementKey = "invalid-key-non-existent" + ) + whenever(libraryRepository.getItemById("item-1")).thenReturn(item) + initViewModel() + + mockChapterContent(url) + + viewModel.loadContent(url, "item-1") + advanceUntilIdle() + + assertEquals(NUM_FOUR, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testStateRestorationBoundary_loadContentEmptyUrl_noOp() = runTest { + initViewModel() + viewModel.loadContent("") + advanceUntilIdle() + assertNull(viewModel.uiState.value.content) + } + + // ========================================================================= + // TIER 3: Cross-Feature Combinations + // ========================================================================= + + @Test + fun testCombo_scrollDuringRestore_marksUserDraggedAndCancelsRestoreLoop() = runTest { + initViewModel() + viewModel.beginRestore() + assertTrue(viewModel.restoreInProgress) + viewModel.markUserDragged() + assertTrue(viewModel.userHasDragged) + viewModel.markRestoreDone() + assertFalse(viewModel.restoreInProgress) + } + + @Test + fun testCombo_scrollMotion_hidesControlsOnUserInput() = runTest { + initViewModel() + viewModel.toggleControls() + assertTrue(viewModel.uiState.value.showControls) + viewModel.hideControls() + assertFalse(viewModel.uiState.value.showControls) + } + + @Test + fun testCombo_drawerSelection_loadsContentAndResetsScrollState() = runTest { + initViewModel() + val url = "https://example.com/drawer-novel" + mockChapterContent(url) + + viewModel.loadContent(url) + advanceUntilIdle() + + assertEquals(url, viewModel.uiState.value.content?.url) + assertEquals(NUM_ZERO, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testCombo_loadContentWithRestore_keepsControlsHidden() = runTest { + val url = "https://example.com/chap" + val item = createLibraryItem("1", url = url, lastReadIndex = NUM_TWO) + whenever(libraryRepository.getItemById("1")).thenReturn(item) + initViewModel() + mockChapterContent(url) + + viewModel.loadContent(url, "1") + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.showControls) + } + + @Test + fun testCombo_drawerNavigationToSameChapter_restoresPreviousPosition() = runTest { + val url = "https://example.com/same" + val item = createLibraryItem("1", url = url, lastReadIndex = NUM_FIVE) + whenever(libraryRepository.getItemById("1")).thenReturn(item) + initViewModel() + mockChapterContent(url) + + viewModel.loadContent(url, "1") + advanceUntilIdle() + assertEquals(NUM_FIVE, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testCombo_controlsShown_doesNotInterruptActiveRestoreState() { + initViewModel() + viewModel.beginRestore() + assertTrue(viewModel.restoreInProgress) + viewModel.toggleControls() + assertTrue(viewModel.uiState.value.showControls) + assertTrue(viewModel.restoreInProgress) + } + + // ========================================================================= + // TIER 4: Real-World Application Scenarios (>=5 scenarios) + // ========================================================================= + + @Test + fun testScenario_coldLaunch_loadsLastReadNovelAndRestoresState() = runTest { + val lastUrl = "https://example.com/last" + val lastItemId = "item-last" + val item = createLibraryItem(lastItemId, url = lastUrl, lastReadIndex = NUM_THREE) + whenever(libraryRepository.getItemById(lastItemId)).thenReturn(item) + mockChapterContent(lastUrl) + + initViewModel(lastReadUrl = lastUrl, lastReadLibraryItemId = lastItemId) + advanceUntilIdle() + + assertEquals(lastUrl, viewModel.uiState.value.content?.url) + assertEquals(NUM_THREE, viewModel.uiState.value.scrollIndex) + } + + @Test + fun testScenario_readingFlow_userUpdatesSettings_updatesUiAndTheme() = runTest { + initViewModel() + mockChapterContent("https://example.com/c1") + viewModel.loadContent("https://example.com/c1") + advanceUntilIdle() + + viewModel.updateFontSize(FLOAT_FONT_SIZE_DEFAULT + FLOAT_ONE) + viewModel.updateReaderTheme(ReaderTheme.OLED) + + val state = viewModel.uiState.value + assertEquals(FLOAT_FONT_SIZE_DEFAULT + FLOAT_ONE, state.fontSize, FLOAT_ZERO) + assertEquals(ReaderTheme.OLED, state.readerTheme) + } + + @Test + fun testScenario_chapterNavigationFlow_loadsNextAndPrevious() = runTest { + val url1 = "https://example.com/c1" + val url2 = "https://example.com/c2" + + val item = createLibraryItem("1", url = url1) + whenever(libraryRepository.getItemById("1")).thenReturn(item) + + initViewModel() + + mockChapterContent(url1, nextUrl = url2) + mockChapterContent(url2, prevUrl = url1) + + viewModel.loadContent(url1, "1") + advanceUntilIdle() + + viewModel.navigateToNextChapter() + advanceUntilIdle() + assertEquals(url2, viewModel.uiState.value.content?.url) + + viewModel.navigateToPreviousChapter(fromBottom = true) + advanceUntilIdle() + assertEquals(url1, viewModel.uiState.value.content?.url) + } + + @Test + fun testScenario_errorFlow_triggersCloudflareDialog_retryLoadsContent() = runTest { + val url = "https://example.com/c1" + initViewModel() + + whenever(contentRepository.loadContent(url)).thenReturn(ContentResult.Error("403 Forbidden", null)) + viewModel.loadContent(url) + advanceUntilIdle() + + assertEquals("403 Forbidden", viewModel.uiState.value.error) + + mockChapterContent(url) + viewModel.retryLoad() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.error) + assertEquals(url, viewModel.uiState.value.content?.url) + } + + @Test + fun testScenario_completionFlow_userReadsToEnd_persistsProgress() = runTest { + val url = "https://example.com/c1" + val item = createLibraryItem("1", url = url) + whenever(libraryRepository.getItemById("1")).thenReturn(item) + + initViewModel() + mockChapterContent(url) + viewModel.loadContent(url, "1") + advanceUntilIdle() + + viewModel.markUserDragged() + viewModel.updateScrollPosition( + scrollOffset = FLOAT_THRESHOLD_VAL, + maxScrollOffset = FLOAT_THRESHOLD_VAL, + viewportHeight = FLOAT_ZERO, + index = NUM_NINE, + offsetFraction = FLOAT_ZERO, + elementKey = "key" + ) + + viewModel.updateReadingProgress( + progress = NUM_ONE_HUNDRED, + scrollPosition = FLOAT_THRESHOLD_VAL, + index = NUM_NINE, + elementKey = "key", + offsetFraction = FLOAT_ZERO, + currentChapterUrl = url, + forcePersist = true + ) + advanceUntilIdle() + + verify(libraryRepository).updateProgressExplicit( + itemId = eq("1"), + currentChapter = any(), + progress = eq(FieldUpdate.Set(NUM_ONE_HUNDRED)), + currentChapterUrl = eq(FieldUpdate.Set(url)), + lastScrollProgress = any(), + lastReadIndex = any(), + lastReadElementKey = any(), + lastReadOffsetFraction = any() + ) + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index e0910713..e87e3184 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -37,8 +37,11 @@ class LibraryViewModelTest { @Before fun setup() { - testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + testDispatcher = StandardTestDispatcher() Dispatchers.setMain(testDispatcher) + LibraryViewModel.isUnderTest = true + LibraryViewModel.defaultDispatcher = testDispatcher + whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(emptyList())) whenever(libraryRepository.loadCollapsedSources()).thenReturn(emptySet()) whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(emptyMap()) @@ -62,6 +65,8 @@ class LibraryViewModelTest { @After fun tearDown() { Dispatchers.resetMain() + LibraryViewModel.isUnderTest = false + LibraryViewModel.defaultDispatcher = Dispatchers.Default } @Test