From d8c0d37a81c5792d92ec6abe600338b45fd1c80d Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 10:33:01 -0400 Subject: [PATCH 01/11] Refactor(reader): extract scroll-restore path, document restore gating Behavior-preserving cleanup of the reader restore logic. - Extract the unified scroll-restore logic out of the ContentArea composable into two named functions: runScrollRestore (orchestrator) and awaitStableRestore (the watch-until-stable loop). The restore effect is now a one-line call. - Restructure the orchestrator's early returns into a single when/handled flow so each function stays within detekt's complexity limits; the loop's essential interrelated conditions carry a documented @Suppress("CyclomaticComplexMethod"). - Document every restore magic constant (poll cadence, stability window, percent tolerance, stability threshold, ghost-row percent, sentinel item size). - Add a doc block above ReaderProgressController's gating flags describing the transitions and the semi-independent axes. No runtime logic, thresholds, control flow, or ordering changed. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../ui/screens/reader/ReaderContentArea.kt | 264 ++++++++++-------- .../ui/viewmodel/ReaderProgressController.kt | 40 +++ 2 files changed, 193 insertions(+), 111 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 243d4e92..7329b275 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 @@ -61,10 +61,22 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.abs +// Post-restore smoke check: if the landed visible percent drifts more than this many +// percentage points from the saved percent, assume async image resize knocked us off and +// fall back to a percent-based scroll. ~half a screen on a typical chapter: big enough to +// ignore pixel-weighted percent noise, small enough to catch a real miss. private const val RESTORE_PERCENT_TOLERANCE = 5f +// Debounce before re-capturing the graphics layer behind the top/bottom edge blur, so a +// burst of scroll events coalesces into a single recapture instead of one per frame. private const val EDGE_BLUR_RECAPTURE_DEBOUNCE_MS = 200L +// Poll cadence for the watch-until-stable restore loop (~5 frames at 60Hz). Fast enough +// that re-applying scroll is imperceptible, slow enough not to busy-spin while images decode. private const val RESTORE_STABILITY_POLL_INTERVAL_MS = 80L +// The target item must hold its position for this long before restore is considered locked +// in — guards against a mid-decode equilibrium that looks stable for only a frame or two. private const val RESTORE_STABILITY_DURATION_MS = 300L +// Hard cap on the restore loop. If images never stabilize (slow network / decode failure) +// we accept the current position rather than spin forever. private const val RESTORE_MAX_WAIT_MS = 3_000L // Skip re-applying fraction unless the target item grew by at least this fraction since // the last applied size. Without it a slow image decode produces 5–10 visible position @@ -140,118 +152,8 @@ internal fun ContentArea( } } - // ─── Unified restore path ─────────────────────────────────────────────── - // Single LaunchedEffect handles BOTH initial load and seek-bar drags. Resolution order: - // 1. element-key anchor (survives chapter reparse) → 2. saved index → 3. percent fallback - // After landing, runs a smoke check: if visible % drifts > RESTORE_PERCENT_TOLERANCE from the - // saved %, falls back to percent-based scroll (defends against async-image-resize drift). LaunchedEffect(content.url, uiState.seekTrigger) { - // Re-arm restore gating on every entry. `calculateInitialPosition` does this for - // the first open, but seek-bar drags bump `seekTrigger` without going through it, - // and the previous restore may have already called `markRestoreDone()`. - readerViewModel.beginRestore() - - if (content.paragraphs.isEmpty()) { - readerViewModel.markRestoreDone() - return@LaunchedEffect - } - - val targetIndex = resolveRestoreIndex(content, uiState) - .coerceIn(0, content.paragraphs.lastIndex) - val targetFraction = uiState.restoreOffsetFraction - .takeIf { it >= 0f } - ?.coerceIn(0f, 1f) - - // Paged mode: page index is the whole position, no intra-page fraction to chase. - if (uiState.isPagedMode) { - runCatching { pagerState.scrollToPage(targetIndex) } - readerViewModel.markRestoreDone() - return@LaunchedEffect - } - - // From-bottom navigation always seeks the final item end. - if (uiState.targetScrollPosition == 100f) { - runCatching { listState.scrollToItem(content.paragraphs.lastIndex, Int.MAX_VALUE) } - readerViewModel.markRestoreDone() - return@LaunchedEffect - } - - // Land at the item first so the LazyList composes it. Offset comes after measurement. - runCatching { listState.scrollToItem(targetIndex, 0) } - - val hasFractionToChase = targetFraction != null && targetFraction > 0f - val chasedFraction = targetFraction?.takeIf { hasFractionToChase } ?: 0f - - // Watch-until-stable: re-apply scrollToItem every time the list state diverges from - // the target. Critical for two reasons: - // 1. Image-heavy chapters (manhwa): items start at placeholder size, so all of them - // fit the viewport and scrollToItem is a no-op (the list isn't scrollable yet). - // Once images decode the list grows, but firstVisibleItemIndex stays at 0 unless - // we re-apply the scroll. - // 2. Intra-item fraction restore: as the target item grows from image decode reflow, - // the absolute pixel offset changes, so the fraction must be re-applied at the - // new size. - // Bails immediately on real user drag. 3s hard cap prevents runaway loops. - val deadline = System.currentTimeMillis() + RESTORE_MAX_WAIT_MS - var lastAppliedSize = 0 - var lastAppliedIndex = -1 - var stableSince = 0L - - while (System.currentTimeMillis() < deadline && !readerViewModel.userHasDragged) { - val onTargetIndex = listState.firstVisibleItemIndex == targetIndex - val size = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == targetIndex } - ?.size ?: 0 - val sizeStable = size >= MIN_STABLE_ITEM_SIZE_PX - // Re-fire scroll if we're not on the target index yet, OR if the target item - // grew enough that the intra-item offset needs recomputing. - val sizeChangedEnough = lastAppliedSize == 0 || - abs(size - lastAppliedSize).toFloat() / - lastAppliedSize.toFloat() >= RESTORE_REJUMP_THRESHOLD - val needsScroll = !onTargetIndex || ( - hasFractionToChase && sizeStable && ( - lastAppliedIndex != targetIndex || - size != lastAppliedSize && sizeChangedEnough - ) - ) - if (needsScroll) { - val offsetPx = if (hasFractionToChase && sizeStable) { - (size * chasedFraction).toInt().coerceIn(0, size) - } else 0 - runCatching { listState.scrollToItem(targetIndex, offsetPx) } - lastAppliedSize = if (sizeStable) size else lastAppliedSize - lastAppliedIndex = targetIndex - stableSince = 0L - } else if (onTargetIndex && (!hasFractionToChase || sizeStable)) { - // Locked in: either no fraction to chase (any landing on target is fine), or - // fraction applied at a stable size. Confirm stability over a short window - // before exiting so a mid-decode equilibrium doesn't fool us. - if (stableSince == 0L) stableSince = System.currentTimeMillis() - if (System.currentTimeMillis() - stableSince >= RESTORE_STABILITY_DURATION_MS) break - } - kotlinx.coroutines.delay(RESTORE_STABILITY_POLL_INTERVAL_MS) - } - - // Final percent-based smoke check. Gates on userHasDragged (not the looser - // hasUserInteractedSinceLoad) so programmatic / reflow-induced scroll events don't - // suppress self-heal. Runs in every imprecise case AND for precise restores with no - // intra-item fraction — catches "landed at the wrong index but seek bar says 89%". - if (!readerViewModel.userHasDragged && - shouldRunPercentRestoreFallback( - isPreciseRestore = uiState.isPreciseRestore, - targetFraction = targetFraction - ) - ) { - val visiblePercent = computeVisiblePercent(listState, content.paragraphs.size) - val targetPercent = uiState.scrollPosition - if (visiblePercent != null && abs(visiblePercent - targetPercent) > RESTORE_PERCENT_TOLERANCE) { - val fallbackIndex = ((targetPercent / 100f) * content.paragraphs.lastIndex).toInt() - .coerceIn(0, content.paragraphs.lastIndex) - runCatching { listState.scrollToItem(fallbackIndex, 0) } - } - } - - readerViewModel.markRestoreDone() + runScrollRestore(content, uiState, listState, pagerState, readerViewModel) } // Detect genuine user drags on the LazyList. Programmatic scrollToItem calls do NOT @@ -477,6 +379,146 @@ internal fun ContentArea( // ─── Restore helpers ──────────────────────────────────────────────────────── +@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, +) { + // ─── Unified restore path ─────────────────────────────────────────────── + // Single entry point handles BOTH initial load and seek-bar drags. Resolution order: + // 1. element-key anchor (survives chapter reparse) → 2. saved index → 3. percent fallback + // After landing, runs a smoke check: if visible % drifts > RESTORE_PERCENT_TOLERANCE from the + // saved %, falls back to percent-based scroll (defends against async-image-resize drift). + + // Re-arm restore gating on every entry. `calculateInitialPosition` does this for + // the first open, but seek-bar drags bump `seekTrigger` without going through it, + // and the previous restore may have already called `markRestoreDone()`. + readerViewModel.beginRestore() + + if (content.paragraphs.isEmpty()) { + readerViewModel.markRestoreDone() + return + } + + val targetIndex = resolveRestoreIndex(content, uiState) + .coerceIn(0, content.paragraphs.lastIndex) + val targetFraction = uiState.restoreOffsetFraction + .takeIf { it >= 0f } + ?.coerceIn(0f, 1f) + + // One-shot jumps: paged mode uses the page index as the whole position; from-bottom + // navigation seeks the final item end. Anything else lands at the item and then chases + // the intra-item fraction via the watch-until-stable loop below. + val handledAsOneShot = when { + uiState.isPagedMode -> { + runCatching { pagerState.scrollToPage(targetIndex) } + true + } + uiState.targetScrollPosition == 100f -> { + runCatching { listState.scrollToItem(content.paragraphs.lastIndex, Int.MAX_VALUE) } + true + } + else -> { + // Land at the item first so the LazyList composes it. Offset comes after measurement. + runCatching { listState.scrollToItem(targetIndex, 0) } + false + } + } + + if (!handledAsOneShot) { + awaitStableRestore(listState, targetIndex, targetFraction, readerViewModel) + + // Final percent-based smoke check. Gates on userHasDragged (not the looser + // hasUserInteractedSinceLoad) so programmatic / reflow-induced scroll events don't + // suppress self-heal. Runs in every imprecise case AND for precise restores with no + // intra-item fraction — catches "landed at the wrong index but seek bar says 89%". + if (!readerViewModel.userHasDragged && + shouldRunPercentRestoreFallback( + isPreciseRestore = uiState.isPreciseRestore, + targetFraction = targetFraction + ) + ) { + val visiblePercent = computeVisiblePercent(listState, content.paragraphs.size) + val targetPercent = uiState.scrollPosition + if (visiblePercent != null && abs(visiblePercent - targetPercent) > RESTORE_PERCENT_TOLERANCE) { + val fallbackIndex = ((targetPercent / 100f) * content.paragraphs.lastIndex).toInt() + .coerceIn(0, content.paragraphs.lastIndex) + runCatching { listState.scrollToItem(fallbackIndex, 0) } + } + } + } + + readerViewModel.markRestoreDone() +} + +// The watch-until-stable loop's re-fire / exit conditions are deliberately interrelated +// (index match, item-size stability, fraction chase, decode-reflow rejump). Splitting them +// further would obscure the algorithm rather than clarify it, so the essential cyclomatic +// complexity is documented inline and suppressed here. +@Suppress("CyclomaticComplexMethod") +private suspend fun awaitStableRestore( + listState: LazyListState, + targetIndex: Int, + targetFraction: Float?, + readerViewModel: ReaderViewModel, +) { + val hasFractionToChase = targetFraction != null && targetFraction > 0f + val chasedFraction = targetFraction?.takeIf { hasFractionToChase } ?: 0f + + // Watch-until-stable: re-apply scrollToItem every time the list state diverges from + // the target. Critical for two reasons: + // 1. Image-heavy chapters (manhwa): items start at placeholder size, so all of them + // fit the viewport and scrollToItem is a no-op (the list isn't scrollable yet). + // Once images decode the list grows, but firstVisibleItemIndex stays at 0 unless + // we re-apply the scroll. + // 2. Intra-item fraction restore: as the target item grows from image decode reflow, + // the absolute pixel offset changes, so the fraction must be re-applied at the + // new size. + // Bails immediately on real user drag. 3s hard cap prevents runaway loops. + val deadline = System.currentTimeMillis() + RESTORE_MAX_WAIT_MS + var lastAppliedSize = 0 + var lastAppliedIndex = -1 + var stableSince = 0L + + while (System.currentTimeMillis() < deadline && !readerViewModel.userHasDragged) { + val onTargetIndex = listState.firstVisibleItemIndex == targetIndex + val size = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == targetIndex } + ?.size ?: 0 + val sizeStable = size >= MIN_STABLE_ITEM_SIZE_PX + // Re-fire scroll if we're not on the target index yet, OR if the target item + // grew enough that the intra-item offset needs recomputing. + val sizeChangedEnough = lastAppliedSize == 0 || + abs(size - lastAppliedSize).toFloat() / + lastAppliedSize.toFloat() >= RESTORE_REJUMP_THRESHOLD + val needsScroll = !onTargetIndex || ( + hasFractionToChase && sizeStable && ( + lastAppliedIndex != targetIndex || + size != lastAppliedSize && sizeChangedEnough + ) + ) + if (needsScroll) { + val offsetPx = if (hasFractionToChase && sizeStable) { + (size * chasedFraction).toInt().coerceIn(0, size) + } else 0 + runCatching { listState.scrollToItem(targetIndex, offsetPx) } + lastAppliedSize = if (sizeStable) size else lastAppliedSize + lastAppliedIndex = targetIndex + stableSince = 0L + } else if (onTargetIndex && (!hasFractionToChase || sizeStable)) { + // Locked in: either no fraction to chase (any landing on target is fine), or + // fraction applied at a stable size. Confirm stability over a short window + // before exiting so a mid-decode equilibrium doesn't fool us. + if (stableSince == 0L) stableSince = System.currentTimeMillis() + if (System.currentTimeMillis() - stableSince >= RESTORE_STABILITY_DURATION_MS) break + } + kotlinx.coroutines.delay(RESTORE_STABILITY_POLL_INTERVAL_MS) + } +} + private fun resolveRestoreIndex( content: ChapterContent, uiState: ReaderViewModel.ReaderUiState 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 16737d1a..d85ed4a3 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 @@ -25,6 +25,34 @@ class ReaderProgressController( var currentLibraryItemId: String? = null + /** + * Restore gating — the flags below encode three semi-independent concerns; they are NOT a + * single linear phase. Transitions: + * + * entry (calculateInitialPosition / beginRestore): + * restoreInProgress = true // layout still reflowing as images decode + * userHasDragged = false // no confirmed touch yet + * suppressAutoNavUntilUserInteraction = true // (calculateInitialPosition only) + * hasUserInteractedSinceLoad = false + * + * restore loop finishes (markRestoreDone): + * restoreInProgress = false // ONLY this clears; suppress/drag unchanged + * + * confirmed user drag / interaction (markUserDragged / onUserInteraction): + * userHasDragged = true; hasUserInteractedSinceLoad = true + * suppressAutoNavUntilUserInteraction = false; restoreInProgress = false + * + * Note: beginRestore() deliberately re-arms restoreInProgress + userHasDragged for a + * mid-flight seek WITHOUT touching suppressAutoNav — the axes are independent. + * + * Write-gating consequences: + * - suppressAutoNavUntilUserInteraction: in-memory state still updates (UI/seek bar stay + * live) but NO DB writes and NO auto-nav until the user acts. + * - restoreInProgress && !userHasDragged: skip DB writes — the position is a mid-reflow + * restore landing, not user intent. + * - hasUserInteractedSinceLoad flips on programmatic scroll too, so self-heal / smoke + * checks gate on userHasDragged (the stricter flag) instead. + */ var suppressAutoNavUntilUserInteraction: Boolean = false var restoredScrollPercent: Float = 0f var hasUserInteractedSinceLoad: Boolean = false @@ -55,10 +83,22 @@ class ReaderProgressController( companion object { private const val TAG = "ReaderProgress" + // 0.5% of an item. Below this an intra-item fraction change is pixel-rounding noise, not + // real movement — skip the DB write. private const val MIN_SCROLL_FRACTION_DELTA_PERMILLE = 5 + // Minimum chapter-percent change worth persisting. With the fraction/index guards this + // collapses a scroll gesture's hundreds of samples into a handful of writes. private const val MIN_SCROLL_PROGRESS_DELTA_PERCENT = 0.35f + // An item smaller than this is almost certainly still a placeholder (real content items are + // taller). Positions measured against it are meaningless and must not pollute the saved row. const val MIN_STABLE_ITEM_SIZE_PX = 96 + // Sentinel "item size" for paged mode and terminal end-of-chapter samples, where there is no + // real intra-item measurement but the position is explicit user intent. Bypasses the + // placeholder-size stability gate in isSnapshotPersistable. const val PAGED_POSITION_ITEM_SIZE_PX = Int.MAX_VALUE + // If a saved row claims index 0 with zero fraction but its percent says we are more than this + // far into the chapter, it is a partial-write ghost row: ignore the index and use the percent + // fallback. private const val SUSPICIOUS_ZERO_INDEX_PERCENT_THRESHOLD = 5f } From 399588bc20fd7dc08ea51c4bf8546c92aff5f500 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 10:40:48 -0400 Subject: [PATCH 02/11] Refactor(reader): drop redundant progress-controller proxy properties ReaderViewModel aliased four ReaderProgressController fields as its own pass-through properties. Three (suppressAutoNavUntilUserInteraction, restoredScrollPercent, restoredProgressSnapshot) were never referenced; hasUserInteractedSinceLoad was read once internally and not by any UI code. Remove all four and route the single remaining read directly through the controller. The controller's own fields are unchanged (its tests use them), and the members still used by the reader UI (userHasDragged, restoreInProgress, currentLibraryItemId) stay. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../ui/viewmodel/ReaderViewModel.kt | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) 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 d58db352..dbecd91a 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 @@ -58,19 +58,6 @@ class ReaderViewModel @Inject constructor( get() = progressController.currentLibraryItemId set(value) { progressController.currentLibraryItemId = value } - // Suppress auto navigation when restoring a saved position until user interacts - private var suppressAutoNavUntilUserInteraction: Boolean - get() = progressController.suppressAutoNavUntilUserInteraction - set(value) { progressController.suppressAutoNavUntilUserInteraction = value } - - private var restoredScrollPercent: Float - get() = progressController.restoredScrollPercent - set(value) { progressController.restoredScrollPercent = value } - - var hasUserInteractedSinceLoad: Boolean - get() = progressController.hasUserInteractedSinceLoad - private set(value) { progressController.hasUserInteractedSinceLoad = value } - val userHasDragged: Boolean get() = progressController.userHasDragged @@ -192,10 +179,6 @@ class ReaderViewModel @Inject constructor( } } - private var restoredProgressSnapshot: ReaderProgressState? - get() = progressController.restoredProgressSnapshot - set(value) { progressController.restoredProgressSnapshot = value } - // Track if we're explicitly navigating (not restoring from library) private var isExplicitNavigation: Boolean = false @@ -1049,7 +1032,7 @@ class ReaderViewModel @Inject constructor( val content = _uiState.value.content ?: return progressController.cancelProgressUpdate() val latest = currentPersistedSnapshot() - val shouldSnapToTop = !hasUserInteractedSinceLoad && + val shouldSnapToTop = !progressController.hasUserInteractedSinceLoad && latest.scrollProgress == 0 && !latest.isPreciseRestore && latest.scrollIndex == 0 && From 4d98b415928aeec25af206d16f3d66cb0699970a Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 10:48:03 -0400 Subject: [PATCH 03/11] Refactor(reader): extract image-dimension pipeline into ImageDimensionManager Move the resolved-image-dimension machinery out of ReaderViewModel into a new ImageDimensionManager: the fine-grained Compose state map, the prompt off-main cache flush, and the trailing-debounced content rebuild (with its jobs and pending/queued maps). ReaderViewModel keeps the ui-state-coupled content rewrite (updateCurrentContentImageDimensions / withResolvedImageDimensions) and passes it to the manager as a callback; it exposes the same public surface (resolvedImageDimensions, persistImageDimensions) by delegation, so callers and the reader UI are unchanged. resetState now calls manager.reset(), preserving the original behavior of leaving the db-flush job and pending map running. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../ui/viewmodel/ImageDimensionManager.kt | 94 +++++++++++++++++++ .../ui/viewmodel/ReaderViewModel.kt | 64 ++----------- 2 files changed, 103 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.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 new file mode 100644 index 00000000..8ef5ca97 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -0,0 +1,94 @@ +package io.aatricks.easyreader.ui.viewmodel + +import androidx.compose.runtime.mutableStateMapOf +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Owns the resolved-image-dimension pipeline extracted from ReaderViewModel. + * + * [applyContentDimensions] is the ViewModel's in-memory content rebuild (it mutates the reader + * ui state, which only the ViewModel can do); everything else — the fine-grained Compose state, + * the prompt off-main cache flush, and the trailing debounce — lives here. + */ +class ImageDimensionManager( + private val scope: CoroutineScope, + 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>() + + private val pendingImageDimensions = LinkedHashMap>() + private val contentDimUpdates = LinkedHashMap>() + private var dimensionFlushJob: Job? = null + private var contentDimApplyJob: Job? = null + + companion object { + private const val IMAGE_DIMENSION_FLUSH_DELAY_MS = 100L + private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L + } + + 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 + scheduleDimensionDbFlush() + scheduleContentDimApply() + } + + // Persist resolved dimensions to the on-disk cache promptly (off-main, no UI cost). + private fun scheduleDimensionDbFlush() { + if (dimensionFlushJob?.isActive == true) return + dimensionFlushJob = scope.launch { + delay(IMAGE_DIMENSION_FLUSH_DELAY_MS) + while (pendingImageDimensions.isNotEmpty()) { + flushPendingImageDimensions() + } + } + } + + // Trailing debounce for the in-memory content rebuild. Applying dimensions per-image rebuilt + // the whole chapter (`paragraphs.map{}` + `content.copy`) and re-emitted ui state on every + // decode, recomposing the reader on the main thread — measured as 9–16ms frame overruns + // (micro-stutter) at 120Hz during scroll. The live image already sizes itself via + // ReaderImageView.runtimeDimensions, so the content mutation only needs to land once loading + // settles (for re-composed items / future restore math). Each new dimension cancels the + // pending apply, so a continuous scroll never rebuilds content mid-fling. + private fun scheduleContentDimApply() { + contentDimApplyJob?.cancel() + contentDimApplyJob = scope.launch { + delay(CONTENT_DIM_APPLY_DEBOUNCE_MS) + if (contentDimUpdates.isEmpty()) return@launch + val batch = contentDimUpdates.toMap() + contentDimUpdates.clear() + applyContentDimensions(batch) + } + } + + private suspend fun flushPendingImageDimensions() { + val updates = pendingImageDimensions.toMap() + pendingImageDimensions.clear() + if (updates.isEmpty()) return + imageDimensionCache.persistAll(updates.map { (url, dimensions) -> + Triple(url, dimensions.first, dimensions.second) + }) + } + + // 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() { + contentDimApplyJob?.cancel() + contentDimUpdates.clear() + resolvedImageDimensions.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 dbecd91a..69d2fea3 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 @@ -49,8 +49,6 @@ class ReaderViewModel @Inject constructor( private const val TAG = "ReaderViewModel" private val DOUBLE_NEWLINE_REGEX = Regex("""\n\s*\n""") private const val MIN_SCROLL_OFFSET_DELTA_PX = 8 - private const val IMAGE_DIMENSION_FLUSH_DELAY_MS = 100L - private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L } // Current library item ID being read @@ -81,54 +79,16 @@ class ReaderViewModel @Inject constructor( // 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. - val resolvedImageDimensions = androidx.compose.runtime.mutableStateMapOf>() - - 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 - scheduleDimensionDbFlush() - scheduleContentDimApply() - } - - // Persist resolved dimensions to the on-disk cache promptly (off-main, no UI cost). - private fun scheduleDimensionDbFlush() { - if (dimensionFlushJob?.isActive == true) return - dimensionFlushJob = viewModelScope.launch { - delay(IMAGE_DIMENSION_FLUSH_DELAY_MS) - while (pendingImageDimensions.isNotEmpty()) { - flushPendingImageDimensions() - } - } - } + private val imageDimensionManager = ImageDimensionManager( + scope = viewModelScope, + imageDimensionCache = imageDimensionCache, + applyContentDimensions = ::updateCurrentContentImageDimensions, + ) - // Trailing debounce for the in-memory content rebuild. Applying dimensions per-image rebuilt - // the whole chapter (`paragraphs.map{}` + `content.copy`) and re-emitted `_uiState` on every - // decode, recomposing the reader on the main thread — measured as 9–16ms frame overruns - // (micro-stutter) at 120Hz during scroll. The live image already sizes itself via - // ReaderImageView.runtimeDimensions, so the content mutation only needs to land once loading - // settles (for re-composed items / future restore math). Each new dimension cancels the - // pending apply, so a continuous scroll never rebuilds content mid-fling. - private fun scheduleContentDimApply() { - contentDimApplyJob?.cancel() - contentDimApplyJob = viewModelScope.launch { - delay(CONTENT_DIM_APPLY_DEBOUNCE_MS) - if (contentDimUpdates.isEmpty()) return@launch - val batch = contentDimUpdates.toMap() - contentDimUpdates.clear() - updateCurrentContentImageDimensions(batch) - } - } + val resolvedImageDimensions get() = imageDimensionManager.resolvedImageDimensions - private suspend fun flushPendingImageDimensions() { - val updates = pendingImageDimensions.toMap() - pendingImageDimensions.clear() - if (updates.isEmpty()) return - imageDimensionCache.persistAll(updates.map { (url, dimensions) -> - Triple(url, dimensions.first, dimensions.second) - }) - } + fun persistImageDimensions(imageUrl: String, width: Int, height: Int) = + imageDimensionManager.persistImageDimensions(imageUrl, width, height) private fun updateCurrentContentImageDimensions(updates: Map>) { updateState { state -> @@ -187,10 +147,6 @@ class ReaderViewModel @Inject constructor( // Job for tracking content loading private var loadJob: Job? = null - private var dimensionFlushJob: Job? = null - private val pendingImageDimensions = LinkedHashMap>() - private var contentDimApplyJob: Job? = null - private val contentDimUpdates = LinkedHashMap>() private fun applyReaderSettings(snapshot: io.aatricks.easyreader.data.local.ReaderSettingsSnapshot) { updateState { @@ -1149,9 +1105,7 @@ class ReaderViewModel @Inject constructor( progressController.resetState() isExplicitNavigation = false lastRawScrollOffset = -1f - contentDimApplyJob?.cancel() - contentDimUpdates.clear() - resolvedImageDimensions.clear() + imageDimensionManager.reset() } private fun closeContent(content: ChapterContent?) { From 7c54924bf9b4f8f44c6b1c41949f36dbe3ba4cba Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 10:58:47 -0400 Subject: [PATCH 04/11] Refactor(web): extract pure element-shaping logic into WebChapterElementShaper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebContentLoader mixed ~130 lines of pure, stateless element shaping (long-strip detection, adjacent-image grouping, wide/double-page splitting) into its I/O and caching orchestration. Move those six functions and their two constants verbatim into a new stateless WebChapterElementShaper object; WebContentLoader now calls through it at the three existing sites. No behavior change. The moved code's pre-existing, already-baselined detekt findings (magic numbers, long lines, nesting/return-count on the grouping functions) are relocated in detekt-baseline.xml from WebContentLoader to WebChapterElementShaper — same findings, new home. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- app/detekt-baseline.xml | 30 ++-- .../content/WebChapterElementShaper.kt | 142 ++++++++++++++++++ .../repository/content/WebContentLoader.kt | 135 +---------------- 3 files changed, 160 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/data/repository/content/WebChapterElementShaper.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index b2dfe2b7..56534c91 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -1,6 +1,6 @@ - + ComplexCondition:HtmlParser.kt$HtmlParser$images.isNotEmpty() && (images.size > 5 || isChapterPage(url) || filteredParagraphs.size < 10) ComplexCondition:ImageIntegrity.kt$ImageIntegrity$header.size >= 12 && header[0] == 'R'.code.toByte() && header[1] == 'I'.code.toByte() && header[2] == 'F'.code.toByte() && header[3] == 'F'.code.toByte() && header[8] == 'W'.code.toByte() && header[9] == 'E'.code.toByte() && header[10] == 'B'.code.toByte() && header[11] == 'P'.code.toByte() @@ -508,10 +508,10 @@ MagicNumber:UrlSecurity.kt$UrlSecurity$31 MagicNumber:UrlSecurity.kt$UrlSecurity$64 MagicNumber:UrlSecurity.kt$UrlSecurity$9 - MagicNumber:WebContentLoader.kt$WebContentLoader$0.05f - MagicNumber:WebContentLoader.kt$WebContentLoader$1.2f - MagicNumber:WebContentLoader.kt$WebContentLoader$1.6 - MagicNumber:WebContentLoader.kt$WebContentLoader$1600 + MagicNumber:WebChapterElementShaper.kt$WebChapterElementShaper$0.05f + MagicNumber:WebChapterElementShaper.kt$WebChapterElementShaper$1.2f + MagicNumber:WebChapterElementShaper.kt$WebChapterElementShaper$1.6 + MagicNumber:WebChapterElementShaper.kt$WebChapterElementShaper$1600 MagicNumber:WebContentLoader.kt$WebContentLoader$250L MagicNumber:WebContentLoader.kt$WebContentLoader$429 MagicNumber:WebContentLoader.kt$WebContentLoader.<no name provided>$0.75f @@ -596,14 +596,14 @@ MaxLineLength:SummaryService.kt$SummaryService$val keywords = listOf("suddenly", "realized", "discovered", "decided", "arrived", "died", "killed", "attacked", "revealed", "secret", "important", "finally", "however", "shocked", "surprised") MaxLineLength:SummaryService.kt$SummaryService$val verbs = listOf("ran", "fought", "grabbed", "rushed", "jumped", "fell", "screamed", "whispered", "turned", "opened") MaxLineLength:TextFormattingPipeline.kt$TextFormattingPipeline$if + MaxLineLength:WebChapterElementShaper.kt$WebChapterElementShaper$finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) + MaxLineLength:WebChapterElementShaper.kt$WebChapterElementShaper$finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) + MaxLineLength:WebChapterElementShaper.kt$WebChapterElementShaper$processed[processed.size - 1] = ContentElement.ImageGroup(listOf(last as ContentElement.Image, element)) + MaxLineLength:WebChapterElementShaper.kt$WebChapterElementShaper$val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) + MaxLineLength:WebChapterElementShaper.kt$WebChapterElementShaper$val lastImages = if (last is ContentElement.ImageGroup) last.images else listOf(last as ContentElement.Image) MaxLineLength:WebContentLoader.kt$WebContentLoader$Log.d(TAG, "Invalid image payload, deleting and retrying: ${UrlSanitizer.sanitize(imageUrl)}") - MaxLineLength:WebContentLoader.kt$WebContentLoader$finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) - MaxLineLength:WebContentLoader.kt$WebContentLoader$finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) MaxLineLength:WebContentLoader.kt$WebContentLoader$if MaxLineLength:WebContentLoader.kt$WebContentLoader$private suspend - MaxLineLength:WebContentLoader.kt$WebContentLoader$processed[processed.size - 1] = ContentElement.ImageGroup(listOf(last as ContentElement.Image, element)) - MaxLineLength:WebContentLoader.kt$WebContentLoader$val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) - MaxLineLength:WebContentLoader.kt$WebContentLoader$val lastImages = if (last is ContentElement.ImageGroup) last.images else listOf(last as ContentElement.Image) MaxLineLength:WebContentLoader.kt$WebContentLoader$val priority = if (mode == PrefetchMode.SPECULATIVE) ImageRequestPriority.SPECULATIVE else ImageRequestPriority.USER_REQUESTED MaxLineLength:WebContentLoader.kt$WebContentLoader$val result = downloadAndCacheImageInternal(imageUrl, pageUrl, ImageRequestPriority.SPECULATIVE, StorageTier.CACHE) MaxLineLength:WebViewUtils.kt$WebViewUtils$userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" @@ -622,9 +622,9 @@ NestedBlockDepth:TextFormattingPipeline.kt$TextFormattingPipeline$private fun mergeAccidentalSplits(paragraphs: List<Pair<String, Int>>, original: String): List<String> NestedBlockDepth:TextFormattingPipeline.kt$TextFormattingPipeline$private fun replaceFourPlusNewlines(text: String): String NestedBlockDepth:TextFormattingPipeline.kt$TextFormattingPipeline$private fun replaceSpacesPlusNewline(text: String, replacementChar: Char): String - NestedBlockDepth:WebContentLoader.kt$WebContentLoader$private fun expandWideElements( groupedElements: List<ContentElement>, url: String ): List<ContentElement> + NestedBlockDepth:WebChapterElementShaper.kt$WebChapterElementShaper$fun expandWideElements(groupedElements: List<ContentElement>, url: String): List<ContentElement> + NestedBlockDepth:WebChapterElementShaper.kt$WebChapterElementShaper$fun groupSimilarElements(elements: List<ContentElement>): List<ContentElement> NestedBlockDepth:WebContentLoader.kt$WebContentLoader$private fun fetchHtmlOnce(client: OkHttpClient, url: String): HtmlFetchOutcome - NestedBlockDepth:WebContentLoader.kt$WebContentLoader$private fun groupSimilarElements(elements: List<ContentElement>): List<ContentElement> NewLineAtEndOfFile:TextHeuristics.kt$io.aatricks.easyreader.util.TextHeuristics.kt RethrowCaughtException:EpubContentLoader.kt$EpubContentLoader$throw e ReturnCount:ChapterListCache.kt$ChapterListCache$fun load(baseNovelUrl: String, sourceName: String): Entry? @@ -680,10 +680,10 @@ ReturnCount:UrlSecurity.kt$UrlSecurity$fun isSafeUrlSynchronous(httpUrl: okhttp3.HttpUrl): Boolean ReturnCount:UrlSecurity.kt$UrlSecurity$fun isUnsafeHostLiteral(host: String): Boolean ReturnCount:UrlSecurity.kt$UrlSecurity$private fun isIpv4MappedIpv6(bytes: ByteArray): Boolean + ReturnCount:WebChapterElementShaper.kt$WebChapterElementShaper$private fun shouldGroupWithLastGroup(last: ContentElement, current: ContentElement.Image): Boolean + ReturnCount:WebChapterElementShaper.kt$WebChapterElementShaper$private fun shouldGroupWithLastImage(last: ContentElement, current: ContentElement.Image): Boolean ReturnCount:WebContentLoader.kt$WebContentLoader$private fun detectMangaReaderHints(document: Document): Boolean ReturnCount:WebContentLoader.kt$WebContentLoader$private fun ensureResultForTier( result: ImageDownloadResult, imageUrl: String, writeTier: StorageTier ): ImageDownloadResult - ReturnCount:WebContentLoader.kt$WebContentLoader$private fun shouldGroupWithLastGroup(last: ContentElement, current: ContentElement.Image): Boolean - ReturnCount:WebContentLoader.kt$WebContentLoader$private fun shouldGroupWithLastImage(last: ContentElement, current: ContentElement.Image): Boolean ReturnCount:WebContentLoader.kt$WebContentLoader$private fun shouldRetryException(e: Exception): Boolean ReturnCount:WebContentLoader.kt$WebContentLoader$private suspend fun enrichParsedCacheDimensions(elements: List<ContentElement>): List<ContentElement> ReturnCount:WebContentLoader.kt$WebContentLoader$private suspend fun executePrefetch( url: String, mode: PrefetchMode, onProgress: (suspend (PrefetchResult) -> Unit)? = null ): PrefetchResult @@ -765,7 +765,7 @@ UnusedParameter:NovelFireSource.kt$NovelFireSource$chaptersUrl: String UnusedParameter:SummaryService.kt$SummaryService$selectedContent: String UnusedParameter:TextFormattingPipeline.kt$TextFormattingPipeline$original: String - UnusedParameter:WebContentLoader.kt$WebContentLoader$url: String + UnusedParameter:WebChapterElementShaper.kt$WebChapterElementShaper$url: String UnusedParameter:ZoomableBox.kt$isDynamic: Boolean = false UnusedPrivateMember:MangaBatSource.kt$MangaBatSource$private suspend fun fetchChaptersFromApi(slug: String): List<ChapterInfo> UnusedPrivateMember:ReaderViewModel.kt$ReaderViewModel$private fun isPlaceholderAtCurrentPosition(index: Int? = null): Boolean diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebChapterElementShaper.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebChapterElementShaper.kt new file mode 100644 index 00000000..84002702 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebChapterElementShaper.kt @@ -0,0 +1,142 @@ +package io.aatricks.easyreader.data.repository.content + +import io.aatricks.easyreader.data.model.ContentElement + +/** + * Pure element-shaping logic lifted out of WebContentLoader: deciding whether a chapter is a + * long vertical strip, grouping adjacent similar images into ImageGroups, and splitting wide + * (double-page) images into left/right halves. No I/O or state — safe to call from any thread. + */ +internal object WebChapterElementShaper { + private const val MAX_IMAGES_PER_GROUP = 3 + private const val MAX_GROUPED_STRIP_RATIO = 4.0f + + // Public entry points (called by WebContentLoader): + fun isLongStripContent(url: String, elements: List): Boolean { + val isManga = url.contains("manga", ignoreCase = true) && + !url.contains("manhwa", ignoreCase = true) && + !url.contains("webtoon", ignoreCase = true) + if (isManga) return false + + val imageCount = elements.sumOf { + when (it) { + is ContentElement.Image -> 1 + is ContentElement.ImageGroup -> it.images.size + else -> 0 + } + } + val textCount = elements.count { it is ContentElement.Text } + return url.contains("manhwa", ignoreCase = true) || + url.contains("webtoon", ignoreCase = true) || + (imageCount > textCount && imageCount > 2) + } + + fun groupSimilarElements(elements: List): List { + if (elements.isEmpty()) return emptyList() + val processed = mutableListOf() + + for (element in elements) { + if (processed.isEmpty()) { + processed.add(element) + continue + } + + val last = processed.last() + when (element) { + is ContentElement.Image -> { + when { + shouldGroupWithLastImage(last, element) -> { + processed[processed.size - 1] = ContentElement.ImageGroup(listOf(last as ContentElement.Image, element)) + } + shouldGroupWithLastGroup(last, element) -> { + val group = last as ContentElement.ImageGroup + processed[processed.size - 1] = ContentElement.ImageGroup(group.images + element) + } + else -> processed.add(element) + } + } + is ContentElement.ImageGroup -> { + when { + shouldGroupWithLastImage(last, element.images.first()) -> { + val lastImages = if (last is ContentElement.ImageGroup) last.images else listOf(last as ContentElement.Image) + processed[processed.size - 1] = ContentElement.ImageGroup(lastImages + element.images) + } + else -> processed.add(element) + } + } + else -> processed.add(element) + } + } + return processed + } + + fun expandWideElements(groupedElements: List, url: String): List { + val finalElements = mutableListOf() + for (element in groupedElements) { + when (element) { + is ContentElement.Image -> { + if (isWideImage(element, url)) { + val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) + if (isManga) { + finalElements.add(element.copy(side = ContentElement.Image.Side.RIGHT)) + finalElements.add(element.copy(side = ContentElement.Image.Side.LEFT)) + } else { + finalElements.add(element.copy(side = ContentElement.Image.Side.LEFT)) + finalElements.add(element.copy(side = ContentElement.Image.Side.RIGHT)) + } + } else { + finalElements.add(element) + } + } + is ContentElement.ImageGroup -> { + // Check if the group as a whole should be split (e.g. all images are wide) + val firstImg = element.images.firstOrNull() + if (firstImg != null && isWideImage(firstImg, url)) { + val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) + if (isManga) { + finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) + finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) + } else { + finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) + finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) + } + } else { + finalElements.add(element) + } + } + else -> finalElements.add(element) + } + } + + return finalElements + } + + // Private helpers (only called by the functions above): + private fun isWideImage(img: ContentElement.Image, url: String): Boolean { + return img.width > img.height * 1.6 && img.width > 1600 && img.height > 0 + } + + private fun shouldGroupWithLastImage(last: ContentElement, current: ContentElement.Image): Boolean { + if (last !is ContentElement.Image || last.width <= 0 || current.width <= 0) return false + if (kotlin.math.abs(last.width - current.width).toFloat() / last.width > 0.05f) return false + if (last.side != ContentElement.Image.Side.FULL || current.side != ContentElement.Image.Side.FULL) { + return false + } + val lastRatio = last.height.toFloat() / last.width + val currentRatio = current.height.toFloat() / current.width + return (currentRatio < 1.2f || lastRatio < 1.2f) && + lastRatio + currentRatio < MAX_GROUPED_STRIP_RATIO + } + + private fun shouldGroupWithLastGroup(last: ContentElement, current: ContentElement.Image): Boolean { + if (last !is ContentElement.ImageGroup) return false + if (current.side != ContentElement.Image.Side.FULL) return false + if (last.images.size >= MAX_IMAGES_PER_GROUP) return false + val lastInGroup = last.images.last() + if (lastInGroup.width <= 0 || current.width <= 0) return false + if (kotlin.math.abs(lastInGroup.width - current.width).toFloat() / lastInGroup.width > 0.05f) return false + val groupRatio = last.images.sumOf { it.height }.toFloat() / lastInGroup.width + val currentRatio = current.height.toFloat() / current.width + return currentRatio < 1.2f && groupRatio + currentRatio < MAX_GROUPED_STRIP_RATIO + } +} 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 c125471b..e33db4b3 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 @@ -62,8 +62,6 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( private const val TAG = "WebContentLoader" private val DIMENSION_SEMAPHORE = Semaphore(20) private const val MAX_CONCURRENT_DOWNLOADS = 4 - private const val MAX_IMAGES_PER_GROUP = 3 - private const val MAX_GROUPED_STRIP_RATIO = 4.0f private const val USER_REQUEST_ATTEMPTS = 4 private const val SHORT_REQUEST_ATTEMPTS = 2 private const val USER_REQUEST_PREFETCH_PASSES = 2 @@ -783,7 +781,7 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( url: String, diskOnly: Boolean ): List { - val isLongStrip = isLongStripContent(url = url, elements = elements) + val isLongStrip = WebChapterElementShaper.isLongStripContent(url = url, elements = elements) val imageElements = elements.flatMap { element -> when (element) { is ContentElement.Image -> listOf(element) @@ -816,29 +814,11 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( } // 2. Group adjacent images/groups before splitting wide ones - val groupedElements = groupSimilarElements(dimensionedElements) + val groupedElements = WebChapterElementShaper.groupSimilarElements(dimensionedElements) - return expandWideElements(groupedElements, url) + return WebChapterElementShaper.expandWideElements(groupedElements, url) } - private fun isLongStripContent(url: String, elements: List): Boolean { - val isManga = url.contains("manga", ignoreCase = true) && - !url.contains("manhwa", ignoreCase = true) && - !url.contains("webtoon", ignoreCase = true) - if (isManga) return false - - val imageCount = elements.sumOf { - when (it) { - is ContentElement.Image -> 1 - is ContentElement.ImageGroup -> it.images.size - else -> 0 - } - } - val textCount = elements.count { it is ContentElement.Text } - return url.contains("manhwa", ignoreCase = true) || - url.contains("webtoon", ignoreCase = true) || - (imageCount > textCount && imageCount > 2) - } private suspend fun enrichImageDimensions( imageElements: List, @@ -867,53 +847,6 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( }.awaitAll() } - private fun expandWideElements( - groupedElements: List, - url: String - ): List { - val finalElements = mutableListOf() - for (element in groupedElements) { - when (element) { - is ContentElement.Image -> { - if (isWideImage(element, url)) { - val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) - if (isManga) { - finalElements.add(element.copy(side = ContentElement.Image.Side.RIGHT)) - finalElements.add(element.copy(side = ContentElement.Image.Side.LEFT)) - } else { - finalElements.add(element.copy(side = ContentElement.Image.Side.LEFT)) - finalElements.add(element.copy(side = ContentElement.Image.Side.RIGHT)) - } - } else { - finalElements.add(element) - } - } - is ContentElement.ImageGroup -> { - // Check if the group as a whole should be split (e.g. all images are wide) - val firstImg = element.images.firstOrNull() - if (firstImg != null && isWideImage(firstImg, url)) { - val isManga = url.contains("manga", ignoreCase = true) && !url.contains("manhwa", ignoreCase = true) - if (isManga) { - finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) - finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) - } else { - finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.LEFT) })) - finalElements.add(ContentElement.ImageGroup(element.images.map { it.copy(side = ContentElement.Image.Side.RIGHT) })) - } - } else { - finalElements.add(element) - } - } - else -> finalElements.add(element) - } - } - - return finalElements - } - - private fun isWideImage(img: ContentElement.Image, url: String): Boolean { - return img.width > img.height * 1.6 && img.width > 1600 && img.height > 0 - } private suspend fun fetchImageDimensions( imageUrl: String, @@ -1455,68 +1388,6 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( return false } - private fun groupSimilarElements(elements: List): List { - if (elements.isEmpty()) return emptyList() - val processed = mutableListOf() - - for (element in elements) { - if (processed.isEmpty()) { - processed.add(element) - continue - } - - val last = processed.last() - when (element) { - is ContentElement.Image -> { - when { - shouldGroupWithLastImage(last, element) -> { - processed[processed.size - 1] = ContentElement.ImageGroup(listOf(last as ContentElement.Image, element)) - } - shouldGroupWithLastGroup(last, element) -> { - val group = last as ContentElement.ImageGroup - processed[processed.size - 1] = ContentElement.ImageGroup(group.images + element) - } - else -> processed.add(element) - } - } - is ContentElement.ImageGroup -> { - when { - shouldGroupWithLastImage(last, element.images.first()) -> { - val lastImages = if (last is ContentElement.ImageGroup) last.images else listOf(last as ContentElement.Image) - processed[processed.size - 1] = ContentElement.ImageGroup(lastImages + element.images) - } - else -> processed.add(element) - } - } - else -> processed.add(element) - } - } - return processed - } - - private fun shouldGroupWithLastImage(last: ContentElement, current: ContentElement.Image): Boolean { - if (last !is ContentElement.Image || last.width <= 0 || current.width <= 0) return false - if (kotlin.math.abs(last.width - current.width).toFloat() / last.width > 0.05f) return false - if (last.side != ContentElement.Image.Side.FULL || current.side != ContentElement.Image.Side.FULL) { - return false - } - val lastRatio = last.height.toFloat() / last.width - val currentRatio = current.height.toFloat() / current.width - return (currentRatio < 1.2f || lastRatio < 1.2f) && - lastRatio + currentRatio < MAX_GROUPED_STRIP_RATIO - } - - private fun shouldGroupWithLastGroup(last: ContentElement, current: ContentElement.Image): Boolean { - if (last !is ContentElement.ImageGroup) return false - if (current.side != ContentElement.Image.Side.FULL) return false - if (last.images.size >= MAX_IMAGES_PER_GROUP) return false - val lastInGroup = last.images.last() - if (lastInGroup.width <= 0 || current.width <= 0) return false - if (kotlin.math.abs(lastInGroup.width - current.width).toFloat() / lastInGroup.width > 0.05f) return false - val groupRatio = last.images.sumOf { it.height }.toFloat() / lastInGroup.width - val currentRatio = current.height.toFloat() / current.width - return currentRatio < 1.2f && groupRatio + currentRatio < MAX_GROUPED_STRIP_RATIO - } private fun primaryCachedFile(url: String, tier: StorageTier): File { val dir = when (tier) { From 74dea2a5fb3b07848d54c554081106b39db5f959 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 11:09:50 -0400 Subject: [PATCH 05/11] Refactor(library): extract multi-select state into LibrarySelectionManager Move LibraryViewModel's selection state (_selectedItems, _selectionModeEnabled) and the toggle/select/deselect/group/all/enter/clear operations into a new LibrarySelectionManager. The ViewModel delegates and reads the manager's flows in its state combine; group- and select-all operations pass their ids in, keeping the manager decoupled from the library list and ui state. No behavior change. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../ui/viewmodel/LibrarySelectionManager.kt | 64 +++++++++++++++++++ .../ui/viewmodel/LibraryViewModel.kt | 47 +++++--------- 2 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibrarySelectionManager.kt diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibrarySelectionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibrarySelectionManager.kt new file mode 100644 index 00000000..afb9076e --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibrarySelectionManager.kt @@ -0,0 +1,64 @@ +package io.aatricks.easyreader.ui.viewmodel + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Owns library multi-select state (which item ids are selected, and whether selection mode is on), + * extracted from LibraryViewModel. Group- and select-all operations take their ids from the caller + * so this holder stays decoupled from the library list and ui state. + */ +class LibrarySelectionManager { + private val _selectedItems = MutableStateFlow>(emptySet()) + val selectedItems: StateFlow> = _selectedItems.asStateFlow() + + private val _selectionModeEnabled = MutableStateFlow(false) + val selectionModeEnabled: StateFlow = _selectionModeEnabled.asStateFlow() + + val selectedIds: Set get() = _selectedItems.value + + fun toggle(itemId: String) { + _selectedItems.update { + val current = it.toMutableSet() + if (!current.add(itemId)) current.remove(itemId) + current + } + } + + fun select(itemId: String) { + _selectedItems.update { it + itemId } + } + + fun deselect(itemId: String) { + _selectedItems.update { it - itemId } + } + + // Matches the original toggleGroupSelection: an empty id list is vacuously "all selected", so + // it takes the subtract branch and is a no-op. + fun toggleGroup(itemIds: List) { + val selected = _selectedItems.value + val allSelected = itemIds.all { it in selected } + val ids = itemIds.toSet() + if (allSelected) { + _selectedItems.update { it - ids } + } else { + _selectedItems.update { it + ids } + } + } + + fun selectAll(allIds: Set) { + _selectionModeEnabled.value = true + _selectedItems.value = allIds + } + + fun enterSelectionMode() { + _selectionModeEnabled.value = true + } + + fun clear() { + _selectedItems.value = emptySet() + _selectionModeEnabled.value = false + } +} 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 f29303bd..f948aa74 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 @@ -58,8 +58,7 @@ class LibraryViewModel @Inject constructor( _statusFilter.value = filter } - private val _selectedItems = MutableStateFlow>(emptySet()) - private val _selectionModeEnabled = MutableStateFlow(false) + private val selectionManager = LibrarySelectionManager() private val _collapsedSources = MutableStateFlow>(emptySet()) private val _chapterCacheStates = MutableStateFlow>(emptyMap()) private val _pendingDeletion = MutableStateFlow>(emptySet()) @@ -144,9 +143,9 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch { val repoFlow = combine( repository.libraryItems, - _selectedItems, + selectionManager.selectedItems, _collapsedSources, - _selectionModeEnabled + selectionManager.selectionModeEnabled ) { items, selected, collapsed, selectionModeEnabled -> Triple(items, selected, collapsed) to selectionModeEnabled } @@ -551,7 +550,7 @@ class LibraryViewModel @Inject constructor( runCatching { updateState { it.copy(isLoading = true) } val items = if (selectedOnly) { - val selectedIds = _selectedItems.value + val selectedIds = selectionManager.selectedIds repository.libraryItems.value.filter { it.id in selectedIds } } else { repository.libraryItems.value @@ -639,48 +638,34 @@ class LibraryViewModel @Inject constructor( } fun toggleSelection(itemId: String): Unit { - _selectedItems.update { - val current = it.toMutableSet() - if (!current.add(itemId)) current.remove(itemId) - current - } + selectionManager.toggle(itemId) } fun selectItem(itemId: String): Unit { - _selectedItems.update { it + itemId } + selectionManager.select(itemId) } fun deselectItem(itemId: String): Unit { - _selectedItems.update { it - itemId } + selectionManager.deselect(itemId) } fun toggleGroupSelection(baseTitle: String): Unit { viewModelScope.launch { - val groupItems = uiState.value.groupedItems[baseTitle] ?: emptyList() - val selectedIds = uiState.value.selectedIds - val allSelected = groupItems.all { it.id in selectedIds } - val itemIds = groupItems.map { it.id } - - if (allSelected) { - _selectedItems.update { it - itemIds.toSet() } - } else { - _selectedItems.update { it + itemIds.toSet() } - } + val itemIds = uiState.value.groupedItems[baseTitle]?.map { it.id } ?: emptyList() + selectionManager.toggleGroup(itemIds) } } fun selectAll(): Unit { - _selectionModeEnabled.value = true - _selectedItems.value = repository.libraryItems.value.map { it.id }.toSet() + selectionManager.selectAll(repository.libraryItems.value.map { it.id }.toSet()) } fun enterSelectionMode(): Unit { - _selectionModeEnabled.value = true + selectionManager.enterSelectionMode() } fun clearSelection(): Unit { - _selectedItems.value = emptySet() - _selectionModeEnabled.value = false + selectionManager.clear() } fun updateSearchQuery(query: String): Unit { @@ -749,11 +734,10 @@ class LibraryViewModel @Inject constructor( } fun removeSelectedItems(): Unit { - val selectedIds = _selectedItems.value + val selectedIds = selectionManager.selectedIds if (selectedIds.isEmpty()) return scheduleDeletion(selectedIds) - _selectedItems.value = emptySet() - _selectionModeEnabled.value = false + selectionManager.clear() } fun clearLibrary(): Unit { @@ -761,8 +745,7 @@ class LibraryViewModel @Inject constructor( runCatching { repository.clearLibrary() contentRepository.clearAllCache() - _selectedItems.value = emptySet() - _selectionModeEnabled.value = false + selectionManager.clear() }.onFailure { e -> updateState { it.copy(error = "Failed to clear library: ${e.message}") } } From 744ba94c72574896e2dbddf55da49f3e55192ee4 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 11:50:05 -0400 Subject: [PATCH 06/11] Refactor(library): extract deletion/undo into LibraryDeletionCoordinator Move LibraryViewModel's pending-deletion state and the 5s undo-window logic (_pendingDeletion, the delete job, schedule/undo/commit/flush/removeImmediate) into a new LibraryDeletionCoordinator. The ViewModel delegates and reads the coordinator's pendingDeletion flow in its state combine; deletion errors surface back through a callback. No behavior change. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../viewmodel/LibraryDeletionCoordinator.kt | 92 +++++++++++++++++++ .../ui/viewmodel/LibraryViewModel.kt | 67 +++----------- 2 files changed, 107 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDeletionCoordinator.kt diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDeletionCoordinator.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDeletionCoordinator.kt new file mode 100644 index 00000000..eeb0c511 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDeletionCoordinator.kt @@ -0,0 +1,92 @@ +package io.aatricks.easyreader.ui.viewmodel + +import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.data.repository.LibraryRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Owns library deletion with a 5s undo window, extracted from LibraryViewModel. Items are marked + * pending (and hidden by the ViewModel's state combine) until the window elapses, then committed + * to the repository and cache. [onError] reports a user-facing message back to the ViewModel. + */ +class LibraryDeletionCoordinator( + private val scope: CoroutineScope, + private val repository: LibraryRepository, + private val contentRepository: ContentRepository, + private val onError: (String) -> Unit, +) { + private val _pendingDeletion = MutableStateFlow>(emptySet()) + val pendingDeletion: StateFlow> = _pendingDeletion.asStateFlow() + + private var pendingDeleteJob: Job? = null + private var pendingDeleteUrls: List = emptyList() + + companion object { + private const val UNDO_DELETE_WINDOW_MS = 5000L + } + + fun schedule(ids: Set) { + if (ids.isEmpty()) return + val items = repository.libraryItems.value + val urls = ids.mapNotNull { id -> items.firstOrNull { it.id == id }?.url } + pendingDeleteJob?.cancel() + pendingDeleteUrls = (pendingDeleteUrls + urls).distinct() + _pendingDeletion.update { it + ids } + pendingDeleteJob = scope.launch { + delay(UNDO_DELETE_WINDOW_MS) + commit() + } + } + + fun undo() { + pendingDeleteJob?.cancel() + pendingDeleteJob = null + _pendingDeletion.value = emptySet() + pendingDeleteUrls = emptyList() + } + + private suspend fun commit() { + val ids = _pendingDeletion.value + if (ids.isEmpty()) return + val urls = pendingDeleteUrls + runCatching { + contentRepository.clearCachesAndDownloadsForUrls(urls) + repository.removeItems(ids) + }.onFailure { e -> + onError("Failed to remove items: ${e.message}") + } + _pendingDeletion.value = emptySet() + pendingDeleteUrls = emptyList() + pendingDeleteJob = null + } + + fun flush() { + pendingDeleteJob?.cancel() + pendingDeleteJob = null + scope.launch { commit() } + } + + fun removeImmediate(ids: Set) { + if (ids.isEmpty()) return + scope.launch { + val urls = repository.libraryItems.value + .asSequence() + .filter { it.id in ids } + .map { it.url } + .toList() + runCatching { + contentRepository.clearCachesAndDownloadsForUrls(urls) + repository.removeItems(ids) + }.onFailure { e -> + onError("Failed to remove items: ${e.message}") + } + } + } +} 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 f948aa74..454f1b17 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 @@ -61,14 +61,15 @@ class LibraryViewModel @Inject constructor( private val selectionManager = LibrarySelectionManager() private val _collapsedSources = MutableStateFlow>(emptySet()) private val _chapterCacheStates = MutableStateFlow>(emptyMap()) - private val _pendingDeletion = MutableStateFlow>(emptySet()) - val pendingDeletion: StateFlow> = _pendingDeletion.asStateFlow() - private var pendingDeleteJob: Job? = null - private var pendingDeleteUrls: List = emptyList() + private val deletionCoordinator = LibraryDeletionCoordinator( + scope = viewModelScope, + repository = repository, + contentRepository = contentRepository, + ) { message -> updateState { it.copy(error = message) } } + val pendingDeletion: StateFlow> = deletionCoordinator.pendingDeletion private var downloadedReconciliationJob: Job? = null companion object { - private const val UNDO_DELETE_WINDOW_MS = 5000L private const val CACHE_STATE_REFRESH_CONCURRENCY = 6 } @@ -150,7 +151,11 @@ class LibraryViewModel @Inject constructor( Triple(items, selected, collapsed) to selectionModeEnabled } - val cacheAndPending = combine(_chapterCacheStates, _pendingDeletion, _statusFilter) { c, p, s -> + val cacheAndPending = combine( + _chapterCacheStates, + deletionCoordinator.pendingDeletion, + _statusFilter + ) { c, p, s -> Triple(c, p, s) } @@ -197,61 +202,19 @@ class LibraryViewModel @Inject constructor( } private fun scheduleDeletion(ids: Set) { - if (ids.isEmpty()) return - val items = repository.libraryItems.value - val urls = ids.mapNotNull { id -> items.firstOrNull { it.id == id }?.url } - pendingDeleteJob?.cancel() - pendingDeleteUrls = (pendingDeleteUrls + urls).distinct() - _pendingDeletion.update { it + ids } - pendingDeleteJob = viewModelScope.launch { - delay(UNDO_DELETE_WINDOW_MS) - commitPendingDeletion() - } + deletionCoordinator.schedule(ids) } fun undoPendingDeletion(): Unit { - pendingDeleteJob?.cancel() - pendingDeleteJob = null - _pendingDeletion.value = emptySet() - pendingDeleteUrls = emptyList() - } - - private suspend fun commitPendingDeletion() { - val ids = _pendingDeletion.value - if (ids.isEmpty()) return - val urls = pendingDeleteUrls - runCatching { - contentRepository.clearCachesAndDownloadsForUrls(urls) - repository.removeItems(ids) - }.onFailure { e -> - updateState { it.copy(error = "Failed to remove items: ${e.message}") } - } - _pendingDeletion.value = emptySet() - pendingDeleteUrls = emptyList() - pendingDeleteJob = null + deletionCoordinator.undo() } fun flushPendingDeletion(): Unit { - pendingDeleteJob?.cancel() - pendingDeleteJob = null - viewModelScope.launch { commitPendingDeletion() } + deletionCoordinator.flush() } fun removeItemsImmediate(ids: Set): Unit { - if (ids.isEmpty()) return - viewModelScope.launch { - val urls = repository.libraryItems.value - .asSequence() - .filter { it.id in ids } - .map { it.url } - .toList() - runCatching { - contentRepository.clearCachesAndDownloadsForUrls(urls) - repository.removeItems(ids) - }.onFailure { e -> - updateState { it.copy(error = "Failed to remove items: ${e.message}") } - } - } + deletionCoordinator.removeImmediate(ids) } private fun filterAndSortItems( From 9f0a2999baa9b6165292a4de4729f2d0aca74007 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 11:58:23 -0400 Subject: [PATCH 07/11] Refactor(library): extract filter/sort criteria into LibraryFilters Move LibraryViewModel's search/content-type/sort/status filter flows, their setters, and the pure filterAndSortItems function into a new LibraryFilters holder. The ViewModel exposes the flows by delegation and applies them via filters.apply() in its state combine. No behavior change. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../easyreader/ui/viewmodel/LibraryFilters.kt | 83 +++++++++++++++++++ .../ui/viewmodel/LibraryViewModel.kt | 73 ++++------------ 2 files changed, 97 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryFilters.kt diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryFilters.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryFilters.kt new file mode 100644 index 00000000..0563b170 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryFilters.kt @@ -0,0 +1,83 @@ +package io.aatricks.easyreader.ui.viewmodel + +import io.aatricks.easyreader.data.model.ContentType +import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.data.model.SeriesReadingStatus +import io.aatricks.easyreader.data.model.SortMode +import io.aatricks.easyreader.data.model.libraryNovelKey +import io.aatricks.easyreader.data.model.seriesReadingStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Holds the library's filter/sort criteria (search text, content-type filter, sort mode, reading + * status) and applies them to a list. Extracted from LibraryViewModel; the ViewModel exposes these + * flows and feeds them plus the library items through [apply] in its state combine. + */ +class LibraryFilters { + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _contentTypeFilter = MutableStateFlow(null) + val contentTypeFilter: StateFlow = _contentTypeFilter.asStateFlow() + + private val _sortMode = MutableStateFlow(SortMode.LAST_READ) + val sortMode: StateFlow = _sortMode.asStateFlow() + + private val _statusFilter = MutableStateFlow(SeriesReadingStatus.ALL) + val statusFilter: StateFlow = _statusFilter.asStateFlow() + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun setContentTypeFilter(contentType: ContentType?) { + _contentTypeFilter.value = contentType + } + + fun setSortMode(mode: SortMode) { + _sortMode.value = mode + } + + fun setStatusFilter(filter: SeriesReadingStatus) { + _statusFilter.value = filter + } + + fun apply( + items: List, + query: String, + filter: ContentType?, + sort: SortMode, + statusFilter: SeriesReadingStatus = SeriesReadingStatus.ALL + ): List { + var filtered = items + + if (filter != null) { + filtered = filtered.filter { it.contentType == filter } + } + + if (statusFilter != SeriesReadingStatus.ALL) { + val seriesGroups = filtered.groupBy { it.libraryNovelKey() } + val matchingKeys = seriesGroups + .filterValues { groupItems -> seriesReadingStatus(groupItems) == statusFilter } + .keys + filtered = filtered.filter { it.libraryNovelKey() in matchingKeys } + } + + if (query.isNotBlank()) { + val lowercaseQuery = query.trim().lowercase() + filtered = filtered.filter { + it.title.lowercase().contains(lowercaseQuery) || + it.baseTitle.lowercase().contains(lowercaseQuery) + } + } + + return when (sort) { + SortMode.LAST_READ -> filtered.sortedByDescending { it.lastRead } + SortMode.DATE_ADDED -> filtered.sortedByDescending { it.dateAdded } + SortMode.TITLE -> filtered.sortedBy { it.title.lowercase() } + SortMode.PROGRESS -> filtered.sortedByDescending { it.progress } + } + } +} 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 454f1b17..1972ca19 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 @@ -9,8 +9,6 @@ import io.aatricks.easyreader.data.model.ExploreItem import io.aatricks.easyreader.data.model.PrefetchResult import io.aatricks.easyreader.data.model.SortMode import io.aatricks.easyreader.data.model.SeriesReadingStatus -import io.aatricks.easyreader.data.model.libraryNovelKey -import io.aatricks.easyreader.data.model.seriesReadingStatus import io.aatricks.easyreader.data.repository.ContentRepository import io.aatricks.easyreader.data.repository.DownloadStatusReconciler import io.aatricks.easyreader.data.repository.ExploreRepository @@ -42,20 +40,14 @@ class LibraryViewModel @Inject constructor( private val TAG = "LibraryViewModel" - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() - - private val _contentTypeFilter = MutableStateFlow(null) - val contentTypeFilter: StateFlow = _contentTypeFilter.asStateFlow() - - private val _sortMode = MutableStateFlow(SortMode.LAST_READ) - val sortMode: StateFlow = _sortMode.asStateFlow() - - private val _statusFilter = MutableStateFlow(SeriesReadingStatus.ALL) - val statusFilter: StateFlow = _statusFilter.asStateFlow() + private val filters = LibraryFilters() + val searchQuery: StateFlow = filters.searchQuery + val contentTypeFilter: StateFlow = filters.contentTypeFilter + val sortMode: StateFlow = filters.sortMode + val statusFilter: StateFlow = filters.statusFilter fun setStatusFilter(filter: SeriesReadingStatus): Unit { - _statusFilter.value = filter + filters.setStatusFilter(filter) } private val selectionManager = LibrarySelectionManager() @@ -154,7 +146,7 @@ class LibraryViewModel @Inject constructor( val cacheAndPending = combine( _chapterCacheStates, deletionCoordinator.pendingDeletion, - _statusFilter + filters.statusFilter ) { c, p, s -> Triple(c, p, s) } @@ -162,16 +154,16 @@ class LibraryViewModel @Inject constructor( combine( repoFlow, cacheAndPending, - _searchQuery, - _contentTypeFilter, - _sortMode + 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 = filterAndSortItems(items, query, filter, sort, statusFilter) + val filteredItems = filters.apply(items, query, filter, sort, statusFilter) LibraryUiState( items = items, @@ -217,43 +209,6 @@ class LibraryViewModel @Inject constructor( deletionCoordinator.removeImmediate(ids) } - private fun filterAndSortItems( - items: List, - query: String, - filter: ContentType?, - sort: SortMode, - statusFilter: SeriesReadingStatus = SeriesReadingStatus.ALL - ): List { - var filtered = items - - if (filter != null) { - filtered = filtered.filter { it.contentType == filter } - } - - if (statusFilter != SeriesReadingStatus.ALL) { - val seriesGroups = filtered.groupBy { it.libraryNovelKey() } - val matchingKeys = seriesGroups - .filterValues { groupItems -> seriesReadingStatus(groupItems) == statusFilter } - .keys - filtered = filtered.filter { it.libraryNovelKey() in matchingKeys } - } - - if (query.isNotBlank()) { - val lowercaseQuery = query.trim().lowercase() - filtered = filtered.filter { - it.title.lowercase().contains(lowercaseQuery) || - it.baseTitle.lowercase().contains(lowercaseQuery) - } - } - - return when (sort) { - SortMode.LAST_READ -> filtered.sortedByDescending { it.lastRead } - SortMode.DATE_ADDED -> filtered.sortedByDescending { it.dateAdded } - SortMode.TITLE -> filtered.sortedBy { it.title.lowercase() } - SortMode.PROGRESS -> filtered.sortedByDescending { it.progress } - } - } - fun addItem( title: String, url: String, @@ -632,15 +587,15 @@ class LibraryViewModel @Inject constructor( } fun updateSearchQuery(query: String): Unit { - _searchQuery.value = query + filters.setSearchQuery(query) } fun setContentTypeFilter(contentType: ContentType?): Unit { - _contentTypeFilter.value = contentType + filters.setContentTypeFilter(contentType) } fun setSortMode(mode: SortMode): Unit { - _sortMode.value = mode + filters.setSortMode(mode) } fun refreshChapterCacheStates(urls: Collection): Unit { From a8cd2c64d56606dc67220a6e14561da8e29712b1 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 12:09:57 -0400 Subject: [PATCH 08/11] Refactor(library): extract download/cache-state into LibraryDownloadStates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move LibraryViewModel's per-chapter download/cache badge state and its reconciliation — the queue observer, on-demand reconcile + auto-resume of incomplete downloads, and the semaphore-limited cache-state refresh — into a new LibraryDownloadStates. The ViewModel delegates the public reconcile/refresh methods, reads the manager's chapterCacheStates in its combine, and routes its three remaining setCacheState writes through it. No behavior change. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../ui/viewmodel/LibraryDownloadStates.kt | 160 ++++++++++++++++++ .../ui/viewmodel/LibraryViewModel.kt | 140 ++------------- 2 files changed, 174 insertions(+), 126 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDownloadStates.kt diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDownloadStates.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDownloadStates.kt new file mode 100644 index 00000000..0b9f8b40 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryDownloadStates.kt @@ -0,0 +1,160 @@ +package io.aatricks.easyreader.ui.viewmodel + +import android.util.Log +import io.aatricks.easyreader.data.model.PrefetchResult +import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.data.repository.DownloadStatusReconciler +import io.aatricks.easyreader.data.repository.LibraryRepository +import io.aatricks.easyreader.work.ChapterDownloadQueue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** + * Owns the per-chapter download/cache badge state and its reconciliation (queue observation, + * on-demand reconcile + auto-resume of incomplete downloads, and semaphore-limited cache-state refresh), + * extracted from LibraryViewModel. + */ +class LibraryDownloadStates( + private val scope: CoroutineScope, + private val repository: LibraryRepository, + private val contentRepository: ContentRepository, + private val downloadStatusReconciler: DownloadStatusReconciler, + private val downloadQueue: ChapterDownloadQueue, +) { + private val _chapterCacheStates = MutableStateFlow>(emptyMap()) + val chapterCacheStates: StateFlow> = _chapterCacheStates.asStateFlow() + private var downloadedReconciliationJob: Job? = null + + companion object { + private const val TAG = "LibraryDownloadStates" + private const val CACHE_STATE_REFRESH_CONCURRENCY = 6 + } + + init { + scope.launch { + downloadQueue.observeAll().collect { results -> + if (results.isEmpty()) return@collect + _chapterCacheStates.update { current -> current + results } + } + } + } + + fun setCacheState(result: PrefetchResult) { + _chapterCacheStates.update { current -> + current + (result.url to result) + } + } + + fun reconcileDownloadedItemsOnDemand() { + if (downloadedReconciliationJob?.isActive == true) return + downloadedReconciliationJob = scope.launch { + val snapshot = runCatching { + @Suppress("USELESS_ELVIS") + repository.getAllItemsSnapshot() ?: emptyList() + }.getOrDefault(emptyList()) + val urls = snapshot.asSequence().map { it.url }.filter { it.isNotBlank() }.toList() + if (urls.isEmpty()) return@launch + val results = refreshChapterCacheStatesSuspend(urls) + val wantedUrls = snapshot.asSequence().filter { it.isDownloaded }.map { it.url }.toSet() + autoResumeIncompleteDownloads(results, wantedUrls) + } + } + + private fun autoResumeIncompleteDownloads(results: List, userWantedUrls: Set) { + // Two ways a chapter qualifies as an in-flight-but-incomplete download: + // - inspect reports it as a persistent download with images still missing + // - DB remembers the user asked for it (isDownloaded=true) but html cache was lost + // (cache eviction, manual clear) — these are invisible to the first condition + // because isPersistentDownload requires htmlCached=true. + val targets = results.filter { result -> + if (result.isInProgress) return@filter false + val wanted = result.isPersistentDownload || result.url in userWantedUrls + if (!wanted) return@filter false + val missingHtml = !result.htmlCached + val missingImages = result.htmlCached && + result.totalImages > 0 && + result.cachedImages < result.totalImages + missingHtml || missingImages + } + if (targets.isEmpty()) return + Log.d(TAG, "Auto-resuming ${targets.size} incomplete downloads") + targets.forEach { state -> + setCacheState( + state.copy( + isInProgress = true, + isRetryable = false, + isPersistentDownload = true + ) + ) + downloadQueue.enqueue(state.url) + } + } + + fun refreshChapterCacheStates(urls: Collection) { + scope.launch { + refreshChapterCacheStatesSuspend(urls) + } + } + + private suspend fun refreshChapterCacheStatesSuspend(urls: Collection): List { + val targetUrls = urls.asSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .toList() + if (targetUrls.isEmpty()) return emptyList() + + val libraryItemsByUrl = repository.libraryItems.value + .asSequence() + .associateBy { it.url } + + val results = supervisorScope { + val semaphore = Semaphore(CACHE_STATE_REFRESH_CONCURRENCY) + targetUrls.map { url -> + async { + semaphore.withPermit { + val item = libraryItemsByUrl[url] + val downloadResult = if (item != null) { + runCatching { contentRepository.inspectDownload(url) }.getOrNull() + } else { + null + } + val useDownloadResult = item?.isDownloaded == true || downloadResult.hasDownloadEvidence() + val result = if (useDownloadResult) { + downloadResult + } else { + runCatching { contentRepository.inspectCache(url) }.getOrNull() + } + if (item != null && result != null && !result.isInProgress) { + downloadStatusReconciler.reconcile( + item, + result, + wasUserInspect = useDownloadResult + ) + } + result + } + } + }.awaitAll().filterNotNull() + } + if (results.isNotEmpty()) { + _chapterCacheStates.update { current -> + current + results.associateBy { it.url } + } + } + return results + } +} + +private fun PrefetchResult?.hasDownloadEvidence(): Boolean = + this != null && (htmlCached || totalImages > 0 || cachedImages > 0 || isInProgress) 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 1972ca19..053dc37a 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 @@ -16,15 +16,9 @@ import io.aatricks.easyreader.data.repository.LibraryRepository import io.aatricks.easyreader.util.TextUtils import io.aatricks.easyreader.util.normalizeChapterList import io.aatricks.easyreader.work.ChapterDownloadQueue -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import javax.inject.Inject import android.util.Log @@ -52,68 +46,27 @@ class LibraryViewModel @Inject constructor( private val selectionManager = LibrarySelectionManager() private val _collapsedSources = MutableStateFlow>(emptySet()) - private val _chapterCacheStates = MutableStateFlow>(emptyMap()) + private val downloadStates = LibraryDownloadStates( + scope = viewModelScope, + repository = repository, + contentRepository = contentRepository, + downloadStatusReconciler = downloadStatusReconciler, + downloadQueue = downloadQueue, + ) private val deletionCoordinator = LibraryDeletionCoordinator( scope = viewModelScope, repository = repository, contentRepository = contentRepository, ) { message -> updateState { it.copy(error = message) } } val pendingDeletion: StateFlow> = deletionCoordinator.pendingDeletion - private var downloadedReconciliationJob: Job? = null - - companion object { - private const val CACHE_STATE_REFRESH_CONCURRENCY = 6 - } init { _collapsedSources.value = repository.loadCollapsedSources() observeLibraryChanges() - observeDownloadQueue() } fun reconcileDownloadedItemsOnDemand(): Unit { - if (downloadedReconciliationJob?.isActive == true) return - downloadedReconciliationJob = viewModelScope.launch { - val snapshot = runCatching { - @Suppress("USELESS_ELVIS") - repository.getAllItemsSnapshot() ?: emptyList() - }.getOrDefault(emptyList()) - val urls = snapshot.asSequence().map { it.url }.filter { it.isNotBlank() }.toList() - if (urls.isEmpty()) return@launch - val results = refreshChapterCacheStatesSuspend(urls) - val wantedUrls = snapshot.asSequence().filter { it.isDownloaded }.map { it.url }.toSet() - autoResumeIncompleteDownloads(results, wantedUrls) - } - } - - private fun autoResumeIncompleteDownloads(results: List, userWantedUrls: Set) { - // Two ways a chapter qualifies as an in-flight-but-incomplete download: - // - inspect reports it as a persistent download with images still missing - // - DB remembers the user asked for it (isDownloaded=true) but html cache was lost - // (cache eviction, manual clear) — these are invisible to the first condition - // because isPersistentDownload requires htmlCached=true. - val targets = results.filter { result -> - if (result.isInProgress) return@filter false - val wanted = result.isPersistentDownload || result.url in userWantedUrls - if (!wanted) return@filter false - val missingHtml = !result.htmlCached - val missingImages = result.htmlCached && - result.totalImages > 0 && - result.cachedImages < result.totalImages - missingHtml || missingImages - } - if (targets.isEmpty()) return - Log.d(TAG, "Auto-resuming ${targets.size} incomplete downloads") - targets.forEach { state -> - setCacheState( - state.copy( - isInProgress = true, - isRetryable = false, - isPersistentDownload = true - ) - ) - downloadQueue.enqueue(state.url) - } + downloadStates.reconcileDownloadedItemsOnDemand() } data class LibraryUiState( @@ -144,7 +97,7 @@ class LibraryViewModel @Inject constructor( } val cacheAndPending = combine( - _chapterCacheStates, + downloadStates.chapterCacheStates, deletionCoordinator.pendingDeletion, filters.statusFilter ) { c, p, s -> @@ -184,14 +137,7 @@ class LibraryViewModel @Inject constructor( } } - private fun observeDownloadQueue(): Unit { - viewModelScope.launch { - downloadQueue.observeAll().collect { results -> - if (results.isEmpty()) return@collect - _chapterCacheStates.update { current -> current + results } - } - } - } + private fun scheduleDeletion(ids: Set) { deletionCoordinator.schedule(ids) @@ -331,7 +277,7 @@ class LibraryViewModel @Inject constructor( sourceName = sourceName ) }.onSuccess { - setCacheState( + downloadStates.setCacheState( PrefetchResult( url = chapter.url, htmlCached = false, @@ -474,7 +420,7 @@ class LibraryViewModel @Inject constructor( repository.libraryItems.value } items.forEach { item -> - setCacheState( + downloadStates.setCacheState( PrefetchResult( url = item.url, htmlCached = false, @@ -499,7 +445,7 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch { runCatching { contentRepository.clearPermanentFailures(url) } .onFailure { e -> Log.w(TAG, "failed to clear permanent failures before retry: ${e.message}") } - setCacheState( + downloadStates.setCacheState( (uiState.value.chapterCacheStates[url] ?: PrefetchResult(url, false, 0, 0, false)) .copy(isInProgress = true, isRetryable = false, isPersistentDownload = true) ) @@ -599,56 +545,7 @@ class LibraryViewModel @Inject constructor( } fun refreshChapterCacheStates(urls: Collection): Unit { - viewModelScope.launch { refreshChapterCacheStatesSuspend(urls) } - } - - private suspend fun refreshChapterCacheStatesSuspend(urls: Collection): List { - val targetUrls = urls.asSequence() - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - .toList() - if (targetUrls.isEmpty()) return emptyList() - - val libraryItemsByUrl = repository.libraryItems.value - .asSequence() - .associateBy { it.url } - - val results = supervisorScope { - val semaphore = Semaphore(CACHE_STATE_REFRESH_CONCURRENCY) - targetUrls.map { url -> - async { - semaphore.withPermit { - val item = libraryItemsByUrl[url] - val downloadResult = if (item != null) { - runCatching { contentRepository.inspectDownload(url) }.getOrNull() - } else { - null - } - val useDownloadResult = item?.isDownloaded == true || downloadResult.hasDownloadEvidence() - val result = if (useDownloadResult) { - downloadResult - } else { - runCatching { contentRepository.inspectCache(url) }.getOrNull() - } - if (item != null && result != null && !result.isInProgress) { - downloadStatusReconciler.reconcile( - item, - result, - wasUserInspect = useDownloadResult - ) - } - result - } - } - }.awaitAll().filterNotNull() - } - if (results.isNotEmpty()) { - _chapterCacheStates.update { current -> - current + results.associateBy { it.url } - } - } - return results + downloadStates.refreshChapterCacheStates(urls) } fun removeSelectedItems(): Unit { @@ -704,17 +601,8 @@ class LibraryViewModel @Inject constructor( } } - private fun setCacheState(result: PrefetchResult): Unit { - _chapterCacheStates.update { current -> - current + (result.url to result) - } - } - } -private fun PrefetchResult?.hasDownloadEvidence(): Boolean = - this != null && (htmlCached || totalImages > 0 || cachedImages > 0 || isInProgress) - internal fun selectLatestChapter(chapters: List): ChapterInfo? { if (chapters.isEmpty()) return null From a107f2818094ac3a048f002eb632028b7fcae8a5 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 12:11:19 -0400 Subject: [PATCH 09/11] Refactor(library): drop @Suppress("LargeClass") from LibraryViewModel With selection, deletion, filters, and download/cache-state extracted into their own collaborators, LibraryViewModel's class body is now under detekt's LargeClass threshold, so the suppression it carried is no longer needed. Verified: ./gradlew detekt (clean, no LargeClass). --- .../java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt | 1 - 1 file changed, 1 deletion(-) 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 053dc37a..c09163df 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 @@ -22,7 +22,6 @@ import kotlinx.coroutines.launch import javax.inject.Inject import android.util.Log -@Suppress("LargeClass") @HiltViewModel class LibraryViewModel @Inject constructor( val repository: LibraryRepository, From 04b8d2a83011f79ce523150f42faeb3805189cf2 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 12:24:44 -0400 Subject: [PATCH 10/11] Refactor(prefs): remove dead legacy SharedPreferences state Drop pre-Room content/session persistence that nothing reads anymore: saveParagraphs/loadParagraphs, saveLibraryItems, the currentUrl / currentTitle / scrollPosition properties, clearContent, and their now-orphaned keys. loadLibraryItems stays (LibraryRepository still uses it for one-time migration to Room). With the dead functions gone, PreferencesManager is under detekt's TooManyFunctions threshold, so its @Suppress is removed too. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- .../data/local/PreferencesManager.kt | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt index 5a90590a..a8dc598e 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt @@ -34,7 +34,6 @@ data class ReaderSettingsSnapshot( * SharedPreferences wrapper for type-safe preferences access */ @Singleton -@Suppress("TooManyFunctions") class PreferencesManager @Inject constructor( @ApplicationContext context: Context ) { @@ -74,11 +73,6 @@ class PreferencesManager @Inject constructor( ?: io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name ) - // Current URL - var currentUrl: String? - get() = prefs.getString(KEY_CURRENT_URL, null) - set(value) = prefs.edit().putString(KEY_CURRENT_URL, value).apply() - // Last-read chapter URL, mirrored on every successful chapter load so cold launch can // restore the reader without waiting for the Room currently-reading query. var lastReadUrl: String? @@ -96,32 +90,7 @@ class PreferencesManager @Inject constructor( .apply() } - // Current paragraphs - fun saveParagraphs(paragraphs: List) { - val jsonString = json.encodeToString(paragraphs) - prefs.edit().putString(KEY_PARAGRAPHS, jsonString).apply() - } - - fun loadParagraphs(): List { - val jsonString = prefs.getString(KEY_PARAGRAPHS, null) ?: return emptyList() - return try { - json.decodeFromString(jsonString) - } catch (e: Exception) { - emptyList() - } - } - - // Scroll position - var scrollPosition: Int - get() = prefs.getInt(KEY_SCROLL_POSITION, 0) - set(value) = prefs.edit().putInt(KEY_SCROLL_POSITION, value).apply() - - // Library items - fun saveLibraryItems(items: List) { - val jsonString = json.encodeToString(items) - prefs.edit().putString(KEY_LIBRARY_ITEMS, jsonString).apply() - } - + // Library items (legacy SharedPreferences store — read only, for one-time migration to Room) fun loadLibraryItems(): List { val jsonString = prefs.getString(KEY_LIBRARY_ITEMS, null) return if (jsonString != null) { @@ -151,11 +120,6 @@ class PreferencesManager @Inject constructor( } } - // Current title for tracking - var currentTitle: String? - get() = prefs.getString(KEY_CURRENT_TITLE, null) - set(value) = prefs.edit().putString(KEY_CURRENT_TITLE, value).apply() - // Last update check time var lastUpdateCheckTime: Long get() = prefs.getLong(KEY_LAST_UPDATE_CHECK, 0L) @@ -207,14 +171,6 @@ class PreferencesManager @Inject constructor( prefs.edit().clear().apply() } - // Clear specific data - fun clearContent() { - prefs.edit() - .remove(KEY_PARAGRAPHS) - .remove(KEY_SCROLL_POSITION) - .apply() - } - /** * Batch update multiple reader settings in a single SharedPreferences transaction. */ @@ -241,14 +197,10 @@ class PreferencesManager @Inject constructor( companion object { private const val PREFS_NAME = "novel_scraper_prefs" - private const val KEY_CURRENT_URL = "current_url" private const val KEY_LAST_READ_URL = "last_read_url" private const val KEY_LAST_READ_LIBRARY_ITEM_ID = "last_read_library_item_id" - private const val KEY_PARAGRAPHS = "paragraphs" - private const val KEY_SCROLL_POSITION = "scroll_position" private const val KEY_LIBRARY_ITEMS = "library_items" private const val KEY_COLLAPSED_SOURCES = "collapsed_sources" - private const val KEY_CURRENT_TITLE = "current_title" private const val KEY_LAST_UPDATE_CHECK = "last_update_check" // Reader Settings Keys From a22931f2422c946caf352ed52990f3536436baf0 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 12:33:45 -0400 Subject: [PATCH 11/11] Refactor: remove dead code grandfathered in the detekt baseline Delete confirmed-unused private members that were baselined rather than removed: nine dead regexes in TextUtils (one a duplicate of MULTIPLE_SPACES_REGEX), HtmlParser's unused WHITESPACE_REGEX, ReaderViewModel's unused MIN_SCROLL_OFFSET_DELTA_PX constant and isPlaceholderAtCurrentPosition (a duplicate of the live one in ReaderProgressController), and MangaBatSource's unused fetchChaptersFromApi fallback. Regenerated detekt-baseline.xml to drop the 13 now-stale entries. Verified: ./gradlew testStandardDebugUnitTest detekt (349 tests, 0 failures, detekt clean). --- app/detekt-baseline.xml | 13 ------------- .../easyreader/data/repository/HtmlParser.kt | 1 - .../data/repository/source/MangaBatSource.kt | 5 ----- .../easyreader/ui/viewmodel/ReaderViewModel.kt | 9 --------- .../java/io/aatricks/easyreader/util/TextUtils.kt | 9 --------- 5 files changed, 37 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 56534c91..36be79f5 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -767,23 +767,10 @@ UnusedParameter:TextFormattingPipeline.kt$TextFormattingPipeline$original: String UnusedParameter:WebChapterElementShaper.kt$WebChapterElementShaper$url: String UnusedParameter:ZoomableBox.kt$isDynamic: Boolean = false - UnusedPrivateMember:MangaBatSource.kt$MangaBatSource$private suspend fun fetchChaptersFromApi(slug: String): List<ChapterInfo> - UnusedPrivateMember:ReaderViewModel.kt$ReaderViewModel$private fun isPlaceholderAtCurrentPosition(index: Int? = null): Boolean UnusedPrivateProperty:ExploreRepository.kt$ExploreRepository$@ApplicationContext private val context: android.content.Context - UnusedPrivateProperty:HtmlParser.kt$HtmlParser.Companion$private val WHITESPACE_REGEX = Regex("\\s+") UnusedPrivateProperty:LibraryRepository.kt$LibraryRepository$val semaphore = Semaphore(5) UnusedPrivateProperty:LlmEdgeSummaryEngine.kt$LlmEdgeSummaryEngine.Companion$private const val MODEL_FILENAME = "Qwen3-0.6B-Q4_K_M.gguf" - UnusedPrivateProperty:ReaderViewModel.kt$ReaderViewModel.Companion$private const val MIN_SCROLL_OFFSET_DELTA_PX = 8 UnusedPrivateProperty:TextFormattingPipeline.kt$TextFormattingPipeline$k - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val FOUR_PLUS_NEWLINES_REGEX = Regex("\\n{4,}") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val LINE_BREAK_REGEX = Regex("\\r\\n|\\r") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val LIST_MARKER_REGEX = Regex("^(?:\\d+|[ivxIVX]+\\.|[-*•])\\s") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val NEWLINE_BEFORE_LOWER_DIGIT_REGEX = Regex("\\n(?=[a-z0-9])") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val PARAGRAPH_SPLIT_REGEX = Regex("(?s)(.*?)(\\n+|$)") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val SINGLE_NEWLINE_REGEX = Regex("(?<!\\n)\\n(?!\\n)") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val SPACE_PLUS_NEWLINE_REGEX = Regex(" +\\n") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val THREE_PLUS_NEWLINES_REGEX = Regex("\\n{3,}") - UnusedPrivateProperty:TextUtils.kt$TextUtils$private val TWO_PLUS_SPACES_REGEX = Regex("[ ]{2,}") VariableNaming:BaseViewModel.kt$BaseViewModel$protected val _uiState = MutableStateFlow(initialState) VariableNaming:LibraryViewModel.kt$LibraryViewModel$private val TAG = "LibraryViewModel" VariableNaming:SummaryViewModel.kt$SummaryViewModel$private val TAG = "SummaryViewModel" diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt index 68bcfe4d..c7ec4a83 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/HtmlParser.kt @@ -16,7 +16,6 @@ import javax.inject.Singleton class HtmlParser @Inject constructor() { companion object { - private val WHITESPACE_REGEX = Regex("\\s+") private val MULTIPLE_SPACES_REGEX = Regex(" +") private val DOUBLE_NEWLINE_REGEX = Regex("\\n\\s*\\n") private val CHAPTER_CLEANUP_PATTERN = Regex("(?i)^(?:chapter|chap|ch|ch\\.)[\\s:\\-\\.]*\\d+\\b.*") diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/source/MangaBatSource.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/source/MangaBatSource.kt index eb96d2f4..b5fce5ff 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/source/MangaBatSource.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/source/MangaBatSource.kt @@ -221,11 +221,6 @@ class MangaBatSource @Inject constructor( }.getOrDefault(Pair(emptyList(), false)) } - private suspend fun fetchChaptersFromApi(slug: String): List { - // Fallback for list elements if needed, but we now use fetchAllChaptersFromApi in getNovelDetails - return fetchAllChaptersFromApi(slug) - } - private fun extractAuthor(document: org.jsoup.nodes.Document): String { val authorByLink = document.select(".table-value a[href*='search/author'], .info-author a").text() if (authorByLink.isNotBlank()) return authorByLink 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 69d2fea3..b943162e 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 @@ -48,7 +48,6 @@ class ReaderViewModel @Inject constructor( companion object { private const val TAG = "ReaderViewModel" private val DOUBLE_NEWLINE_REGEX = Regex("""\n\s*\n""") - private const val MIN_SCROLL_OFFSET_DELTA_PX = 8 } // Current library item ID being read @@ -534,14 +533,6 @@ class ReaderViewModel @Inject constructor( return libraryItem.takeIf { it.contentType == ContentType.PDF }?.lastReadIndex } - private fun isPlaceholderAtCurrentPosition(index: Int? = null): Boolean { - val lastIndex = index ?: _uiState.value.scrollIndex - val paragraphs = _uiState.value.content?.paragraphs ?: return false - val currentItem = paragraphs.getOrNull(lastIndex) - return currentItem is ContentElement.Placeholder || - (currentItem is ContentElement.Text && currentItem.content.startsWith("Loading page")) - } - private suspend fun saveCurrentProgress(): Unit { progressController.saveCurrentProgress(_uiState.value.content) } diff --git a/app/src/main/java/io/aatricks/easyreader/util/TextUtils.kt b/app/src/main/java/io/aatricks/easyreader/util/TextUtils.kt index b40efbdc..b76fbcf7 100644 --- a/app/src/main/java/io/aatricks/easyreader/util/TextUtils.kt +++ b/app/src/main/java/io/aatricks/easyreader/util/TextUtils.kt @@ -13,15 +13,6 @@ object TextUtils { private val PAGE_WORD_REGEX = Regex("Page \\|\\s*|Page\\s+") private val WHITESPACE_REGEX = Regex("\\s+") private val MULTIPLE_SPACES_REGEX = Regex(" +\\n") - private val LINE_BREAK_REGEX = Regex("\\r\\n|\\r") - private val SPACE_PLUS_NEWLINE_REGEX = Regex(" +\\n") - private val FOUR_PLUS_NEWLINES_REGEX = Regex("\\n{4,}") - private val THREE_PLUS_NEWLINES_REGEX = Regex("\\n{3,}") - private val PARAGRAPH_SPLIT_REGEX = Regex("(?s)(.*?)(\\n+|$)") - private val LIST_MARKER_REGEX = Regex("^(?:\\d+|[ivxIVX]+\\.|[-*•])\\s") - private val NEWLINE_BEFORE_LOWER_DIGIT_REGEX = Regex("\\n(?=[a-z0-9])") - private val SINGLE_NEWLINE_REGEX = Regex("(?