diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index ba21243d..87d170c2 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -70,7 +70,7 @@
FunctionNaming:FileConfirmationDialog.kt$@Composable fun FileConfirmationDialog( fileUri: String, onConfirm: () -> Unit, onCancel: () -> Unit )
FunctionNaming:GridStateTiles.kt$@Composable fun ErrorTile( message: String, onRetry: (() -> Unit)? = null, modifier: Modifier = Modifier )
FunctionNaming:GridStateTiles.kt$@Composable fun LoadingTile( modifier: Modifier = Modifier, height: androidx.compose.ui.unit.Dp = 180.dp )
- FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit ): Unit
+ FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit ): Unit
FunctionNaming:LibraryDrawerContent.kt$@Composable private fun ContinueReadingCard( item: LibraryItem, onClick: () -> Unit ): Unit
FunctionNaming:LibraryDrawerContent.kt$@Composable private fun DrawerSectionLabel(text: String): Unit
FunctionNaming:LibraryDrawerContent.kt$@Composable private fun EmptyQuickAccessState(): Unit
@@ -149,7 +149,7 @@
LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreScreen( exploreViewModel: ExploreViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, onReadItem: (ExploreItem) -> Unit ): Unit
LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ActiveFilterBar( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearFilters: () -> Unit ): Unit
LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun FiltersBottomSheetContent( uiState: ExploreViewModel.ExploreUiState, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearTags: () -> Unit, onClose: () -> Unit ): Unit
- LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit ): Unit
+ LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit ): Unit
LongMethod:LibraryItemCard.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryItemCard( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null )
LongMethod:LibraryRepository.kt$LibraryRepository$suspend fun refreshLibraryUpdates( exploreRepository: ExploreRepository, ignoreActivityThreshold: Boolean = false ): Unit
LongMethod:LibraryScreen.kt$@Composable private fun AddNovelSection( urlInput: String, onUrlChange: (String) -> Unit, onAddClick: () -> Unit, onOpenPdfClick: () -> Unit ): Unit
@@ -184,6 +184,7 @@
LongParameterList:ExploreGridSection.kt$( gridState: LazyGridState, modifier: Modifier = Modifier, uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onItemSelect: (ExploreItem) -> Unit, onLoadMore: () -> Unit, onClearFilters: () -> Unit, onRetryFailedSource: (String) -> Unit )
LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onClearFilters: () -> Unit )
LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, modifier: Modifier = Modifier, hasActiveFilters: Boolean, onSearchQueryChange: (String) -> Unit, onPerformSearch: () -> Unit, onOpenFilters: () -> Unit, onClearFilters: () -> Unit, onSetBrowseMode: (BrowseMode) -> Unit, onSourceSelect: (String?) -> Unit, onTagToggle: (String) -> Unit, onItemSelect: (ExploreItem) -> Unit, onLoadMore: () -> Unit, onRetryFailedSource: (String) -> Unit )
+ LongParameterList:LibraryDrawerContent.kt$( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit, onLibraryClick: () -> Unit, onDiscoverClick: () -> Unit, onOpenLibraryItem: (LibraryItem) -> Unit, onOpenLatestUpdate: (LibraryItem) -> Unit )
LongParameterList:LibraryItemCard.kt$( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null )
LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String = "", progress: FieldUpdate<Int> = FieldUpdate.Unchanged, currentChapterUrl: FieldUpdate<String> = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate<Float> = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate<Int> = FieldUpdate.Unchanged, lastReadElementKey: FieldUpdate<String> = FieldUpdate.Unchanged, lastReadOffsetFraction: FieldUpdate<Float> = FieldUpdate.Unchanged )
LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String, progress: Int, currentChapterUrl: String? = null, lastScrollProgress: Float? = null, lastReadIndex: Int? = null, lastReadElementKey: String? = null, lastReadOffsetFraction: Float? = null )
@@ -301,12 +302,8 @@
MagicNumber:BaseJsoupSource.kt$BaseJsoupSource$15000L
MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$0xff
MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$4
- MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$3
MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$30
- MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$4
- MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$5
MagicNumber:ChapterListSheet.kt$100
- MagicNumber:EasyReaderApplication.kt$EasyReaderApplication$0.25
MagicNumber:EpubContentLoader.kt$EpubContentLoader$1024
MagicNumber:EpubContentLoader.kt$EpubContentLoader$50
MagicNumber:EpubContentLoader.kt$EpubContentLoader.<no name provided>$5
@@ -579,6 +576,8 @@
MaxLineLength:ReaderProgressController.kt$ReaderProgressController$"saveProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(resolvedChapterUrl)} index=$lastIndex elementKey=${if (lastElementKey.isNotEmpty()) "<set>" else "<empty>"} fraction=$lastFraction firstVisibleItemSize=${latest.firstVisibleItemSize}"
MaxLineLength:ReaderProgressController.kt$ReaderProgressController$maxScrollOffset > viewportHeight -> ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f)
MaxLineLength:ReaderProgressController.kt$ReaderProgressController$val nextUiPendingRestoreOffsetFraction = if (uiPendingRestoreOffsetFraction != null) null else uiPendingRestoreOffsetFraction
+ MaxLineLength:ReaderScreen.kt$libraryViewModel
+ MaxLineLength:ReaderScreen.kt$val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url
MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip snap-to-top url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} dbProgress=${existing.progress}"
MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip unstable url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} firstVisibleItemSize=${latest.firstVisibleItemSize} fraction=${latest.scrollOffsetFraction}"
MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} index=${latest.scrollIndex} fraction=${latest.scrollOffsetFraction} firstVisibleItemSize=${latest.firstVisibleItemSize}"
@@ -630,8 +629,9 @@
ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseJpeg(bytes: ByteArray): Pair<Int, Int>?
ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parsePng(bytes: ByteArray): Pair<Int, Int>?
ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseWebP(bytes: ByteArray): Pair<Int, Int>?
- ReturnCount:ImageCache.kt$ImageCache$fun promoteToDownloads(url: String): File?
ReturnCount:ImageCache.kt$ImageCache$private fun File.isCachedImageValid(): Boolean
+ ReturnCount:ImageCache.kt$ImageCache$private fun promoteToDownloadsInternal(url: String): File?
+ ReturnCount:ImageDimensionCacheRepository.kt$ImageDimensionCacheRepository$suspend fun persistAll(entries: List<Triple<String, Int, Int>>): Boolean
ReturnCount:ImageDownloader.kt$ImageDownloader$private fun parseImageResponse( response: Response, destinationFile: File? = null ): ImageFetchResult
ReturnCount:ImageDownloader.kt$ImageDownloader$suspend fun executeImageRequest( imageUrl: String, pageUrl: String, priority: ImageRequestPriority, rangeHeader: String? = null, destinationFile: File? = null ): ImageFetchResult
ReturnCount:ImageIntegrity.kt$ImageIntegrity$fun isValidImageFile(file: File): Boolean
diff --git a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
index de063b87..925db0bb 100644
--- a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
+++ b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt
@@ -138,13 +138,25 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Confi
}
private fun buildMemoryCache(context: PlatformContext): MemoryCache {
+ // Manhwa strips are region-decoded into HARDWARE-config slices (~screenWidth x 2048 x 4B
+ // ≈ 9MB each). At 0.25 the strong LRU (~32-64MB) holds only ~4-7 slices — well under one
+ // chapter, so a fast up/down drag or scrolling back re-decodes evicted slices every time.
+ // HARDWARE pixels live in graphics memory (AHardwareBuffer), not the Java heap, so a larger
+ // budget does not raise heap-OOM risk; 0.5 comfortably holds a full oscillation window.
return MemoryCache.Builder()
- .maxSizePercent(context, 0.25)
+ .maxSizePercent(context, MEMORY_CACHE_HEAP_FRACTION)
.build()
}
companion object {
private const val TAG = "EasyReaderApplication"
private const val WEB_OFFLINE_PIPELINE_VERSION = 3
+
+ // Fraction of the app's available memory the Coil image cache may use. Manhwa strips are
+ // decoded into HARDWARE-config slices (~9MB each, in graphics memory not the Java heap), so
+ // a larger budget lets a full up/down oscillation window and >1 chapter's slices stay
+ // resident instead of being evicted and re-decoded on scroll-back. Dial back toward 0.25 if
+ // low-memory devices hit onTrimMemory / AHardwareBuffer-fd pressure.
+ private const val MEMORY_CACHE_HEAP_FRACTION = 0.5
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt
index 0e4aeabe..fab379e2 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterContent.kt
@@ -70,12 +70,15 @@ data class ChapterContent(
)
/**
- * Get all image URLs
+ * Get all image URLs, including images nested inside [ContentElement.PageContent]
*/
- fun getAllImageUrls(): List = paragraphs.flatMap {
+ fun getAllImageUrls(): List = collectImageUrls(paragraphs)
+
+ private fun collectImageUrls(elements: List): List = elements.flatMap {
when (it) {
is ContentElement.Image -> listOf(it.url)
is ContentElement.ImageGroup -> it.images.map { img -> img.url }
+ is ContentElement.PageContent -> collectImageUrls(it.elements)
else -> emptyList()
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt
index eaa702ce..c4ae9be9 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt
@@ -41,8 +41,9 @@ class ImageDimensionCacheRepository @Inject constructor(
}
}
- suspend fun persistAll(entries: List>) {
- if (entries.isEmpty()) return
+ /** @return true when the rows reached the DB (or there was nothing valid to write). */
+ suspend fun persistAll(entries: List>): Boolean {
+ if (entries.isEmpty()) return true
val now = System.currentTimeMillis()
val rows = entries
.filter { it.first.isNotBlank() && it.second > 0 && it.third > 0 }
@@ -55,8 +56,8 @@ class ImageDimensionCacheRepository @Inject constructor(
parserVersion = CURRENT_PARSER_VERSION
)
}
- if (rows.isEmpty()) return
- runCatching { dao.upsertAll(rows) }
+ if (rows.isEmpty()) return true
+ return runCatching { dao.upsertAll(rows) }.isSuccess
}
suspend fun prune(ttlMs: Long = DEFAULT_TTL_MS) {
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt
index 07c5551a..126cf9db 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt
@@ -23,18 +23,47 @@ class ImageCache @Inject constructor(
fun getLikelyCachedMediaFile(url: String): File? =
candidateFiles(url).firstOrNull { it.exists() && it.length() > 0L }
+ // Memoized: the reader probes this on the main thread for every image item entering
+ // composition (twice per item before the memo), and a fast up/down scroll re-enters items
+ // continuously — up to 3 candidate files × exists/length/mtime syscalls each time. Entries
+ // are dropped whenever a write path touches the url's candidate files (see mutators below
+ // and WebContentLoader.downloadAndCacheImageInternal); staleness is otherwise harmless
+ // because HttpMediaCacheFetcher re-checks disk authoritatively before serving.
fun getLikelyMediaState(url: String): String {
+ // Hit path stays lookup-only (same shape as isCachedImageValid below): the size guard
+ // both costs a CHM sweep and, when it trips, would wipe the live chapter's hot entries
+ // on what was a plain read.
+ mediaStateMemo[url]?.let { return it }
+ if (mediaStateMemo.size > MAX_MEDIA_STATE_MEMO) mediaStateMemo.clear()
+ return mediaStateMemo.computeIfAbsent(url) { computeLikelyMediaState(it) }
+ }
+
+ private fun computeLikelyMediaState(url: String): String {
val file = getLikelyCachedMediaFile(url) ?: return "missing"
return "${file.absolutePath}:${file.length()}:${file.lastModified()}"
}
+ fun invalidateMediaState(url: String) {
+ mediaStateMemo.remove(url)
+ }
+
fun getRootDir(): File = mediaCacheDir
fun getCacheSize(): Long = FileSizeUtils.calculateDirectorySize(mediaCacheDir)
fun getDownloadsSize(): Long = FileSizeUtils.calculateDirectorySize(mediaDownloadsDir)
- fun trimToSize(maxBytes: Long): Long = FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes)
+ fun trimToSize(maxBytes: Long): Long {
+ // Trim runs routinely (after chapter loads, every ~30s during prefetch) and usually
+ // deletes nothing — the memo must survive those calls or the memoization is defeated.
+ // When files ARE deleted there is no per-url mapping back from file names, so drop the
+ // whole memo, and only AFTER the walk finishes: clearing first would let a concurrent
+ // probe re-memoize a file the walk is about to delete.
+ var deletedAny = false
+ val remaining = FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) { deletedAny = true }
+ if (deletedAny) mediaStateMemo.clear()
+ return remaining
+ }
fun findExistingCachedMediaFile(url: String): File? =
candidateFiles(url).firstOrNull { it.exists() && it.isCachedImageValid() }
@@ -59,6 +88,8 @@ class ImageCache @Inject constructor(
private data class IntegrityKey(val path: String, val length: Long, val mtime: Long)
private val integrityVerdicts = ConcurrentHashMap()
+ private val mediaStateMemo = ConcurrentHashMap()
+
private fun File.isCachedImageValid(): Boolean {
if (!exists() || length() <= 0L) return false
val key = IntegrityKey(absolutePath, length(), lastModified())
@@ -71,27 +102,40 @@ class ImageCache @Inject constructor(
private companion object {
private const val MAX_INTEGRITY_VERDICTS = 4096
+ private const val MAX_MEDIA_STATE_MEMO = 4096
}
+ // Memo invalidation always FOLLOWS the disk mutation: invalidating first leaves a window
+ // where a concurrent probe re-memoizes the pre-mutation state, which then sticks.
fun deleteCachedMediaFiles(url: String) {
candidateFiles(url).forEach { it.delete() }
+ mediaStateMemo.remove(url)
}
fun deleteDownloadedMediaFile(url: String) {
File(mediaDownloadsDir, CacheKeyUtils.keyFor(url)).delete()
+ mediaStateMemo.remove(url)
}
fun clearAll() {
mediaCacheDir.deleteRecursively()
mediaCacheDir.mkdirs()
+ mediaStateMemo.clear()
}
fun clearAllDownloads() {
mediaDownloadsDir.deleteRecursively()
mediaDownloadsDir.mkdirs()
+ mediaStateMemo.clear()
+ }
+
+ fun promoteToDownloads(url: String): File? = try {
+ promoteToDownloadsInternal(url)
+ } finally {
+ mediaStateMemo.remove(url)
}
- fun promoteToDownloads(url: String): File? {
+ private fun promoteToDownloadsInternal(url: String): File? {
val key = CacheKeyUtils.keyFor(url)
val target = File(mediaDownloadsDir, key)
if (target.exists()) {
diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt
index 12ffc1b3..16924de4 100644
--- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt
+++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/WebContentLoader.kt
@@ -1018,6 +1018,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor(
}
}.also { created ->
created.invokeOnCompletion {
+ // Invalidate here — when the download attempt itself finishes — not in
+ // the awaiter: the deferred is shared and outlives a cancelled awaiter
+ // (Coil disposing a request mid-download), and an awaiter-side
+ // invalidation would fire before the file lands, letting a probe
+ // re-memoize "missing" that nothing ever corrects.
+ imageCache.invalidateMediaState(imageUrl)
repositoryScope.launch {
imageDownloadMutex.withLock {
if (inFlightImageDownloads[imageUrl]?.deferred === created) {
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt
index 43e52f9b..f006dfd9 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt
@@ -118,10 +118,11 @@ fun ReaderImageView(
dynamicHeight = dynamicHeight
)
var runtimeDimensions by remember(imageUrl, pageUrl) { mutableStateOf(null) }
- // Shared, fine-grained resolved dimensions (survives this item being recycled). Lets a
+ // Shared, per-URL resolved dimensions (survive this item being recycled). Lets a
// re-entering item size itself on first composition instead of collapsing to the loading
// placeholder and re-laying-out — the cause of stutter when dragging up/down quickly.
- val sharedDimensions = readerViewModel.resolvedImageDimensions[imageUrl]
+ // Reading through a per-URL State means a write for another image cannot invalidate this one.
+ val sharedDimensions by remember(imageUrl) { readerViewModel.imageDimensionState(imageUrl) }
val effectiveDimensions = effectiveImageDimensions(
declaredWidth = width,
declaredHeight = height,
@@ -133,14 +134,22 @@ fun ReaderImageView(
val aspectRatioModifier = Modifier.imageAspectRatio(side, effectiveWidth, effectiveHeight)
val hasResolvedAspectRatio = effectiveDimensions != null
- // Single, idempotent cache probe used to seed loading state and the loading UI choice.
- // Performed once per imageUrl on the Composition thread (down from 3–4 File ops previously).
- // Coil's HttpMediaCacheFetcher owns the authoritative disk check inside its own dispatcher.
+ // Single, idempotent cache probe shared with the request cache key below (one
+ // getLikelyMediaState call per composition entry — the repository memoizes it, so item
+ // re-entries during a scroll don't re-stat disk on the main thread). Coil's
+ // HttpMediaCacheFetcher owns the authoritative disk check inside its own dispatcher.
+ var retryTrigger by remember(imageUrl, pageUrl) { mutableStateOf(0L) }
+ val localMediaState = remember(imageUrl, retryTrigger) {
+ if (imageUrl.startsWith("http")) {
+ readerViewModel.contentRepository.getLikelyMediaState(imageUrl)
+ } else {
+ ""
+ }
+ }
val isInitiallyCached = remember(imageUrl) {
when {
imageUrl.startsWith("file") -> true
- imageUrl.startsWith("http") ->
- readerViewModel.contentRepository.getLikelyMediaState(imageUrl) != "missing"
+ imageUrl.startsWith("http") -> localMediaState != "missing"
else -> false
}
}
@@ -213,14 +222,6 @@ fun ReaderImageView(
isCached = isInitiallyCached
)
- var retryTrigger by remember(imageUrl, pageUrl) { mutableStateOf(0L) }
- val localMediaState = remember(imageUrl, retryTrigger) {
- if (imageUrl.startsWith("http")) {
- readerViewModel.contentRepository.getLikelyMediaState(imageUrl)
- } else {
- ""
- }
- }
// Keys are only the inputs that actually change the request. isInitiallyCached is stable
// for the lifetime of the composition, so it does not need to be a key — capturing the
// value once avoids the re-fetch loop that previously fired when the success handler
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt
index ba1c91de..94e51a33 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt
@@ -12,35 +12,26 @@ import androidx.compose.material.icons.filled.ArrowOutward
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
import io.aatricks.easyreader.data.model.LibraryItem
-import io.aatricks.easyreader.ui.ExploreRoute
-import io.aatricks.easyreader.ui.LibraryRoute
import io.aatricks.easyreader.ui.theme.EasyReaderSpacing
-import io.aatricks.easyreader.ui.viewmodel.LibraryViewModel
-import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel
@Composable
fun LibraryDrawerContent(
- libraryViewModel: LibraryViewModel,
- readerViewModel: ReaderViewModel,
- navController: NavController,
+ drawerSections: DrawerNovelSections,
+ isLibraryEmpty: Boolean,
onOpenFilePicker: () -> Unit,
- onCloseDrawer: () -> Unit
+ onCloseDrawer: () -> Unit,
+ onLibraryClick: () -> Unit,
+ onDiscoverClick: () -> Unit,
+ onOpenLibraryItem: (LibraryItem) -> Unit,
+ onOpenLatestUpdate: (LibraryItem) -> Unit
): Unit {
- val libraryUiState by libraryViewModel.uiState.collectAsState()
- val drawerSections = remember(libraryUiState.items) {
- buildDrawerNovelSections(libraryUiState.items)
- }
val continueNovel = drawerSections.continueNovel
val recentUpdates = drawerSections.recentUpdates
val recentItems = drawerSections.recentNovels
@@ -60,9 +51,7 @@ fun LibraryDrawerContent(
FilledTonalButton(
onClick = {
onCloseDrawer()
- navController.navigate(LibraryRoute) {
- launchSingleTop = true
- }
+ onLibraryClick()
},
modifier = Modifier.weight(1f)
) {
@@ -71,9 +60,7 @@ fun LibraryDrawerContent(
OutlinedButton(
onClick = {
onCloseDrawer()
- navController.navigate(ExploreRoute) {
- launchSingleTop = true
- }
+ onDiscoverClick()
},
modifier = Modifier.weight(1f)
) {
@@ -105,12 +92,8 @@ fun LibraryDrawerContent(
ContinueReadingCard(
item = continueNovel.resumeItem,
onClick = {
- openLibraryItem(
- item = continueNovel.resumeItem,
- libraryViewModel = libraryViewModel,
- readerViewModel = readerViewModel,
- onCloseDrawer = onCloseDrawer
- )
+ onOpenLibraryItem(continueNovel.resumeItem)
+ onCloseDrawer()
}
)
}
@@ -124,12 +107,8 @@ fun LibraryDrawerContent(
supportingText = "Start at the newest chapter",
trailingLabel = "Open latest",
onClick = {
- openLatestUpdateItem(
- item = novel.updateItem,
- libraryViewModel = libraryViewModel,
- readerViewModel = readerViewModel,
- onCloseDrawer = onCloseDrawer
- )
+ onOpenLatestUpdate(novel.updateItem)
+ onCloseDrawer()
}
)
}
@@ -145,18 +124,14 @@ fun LibraryDrawerContent(
if (chapter.startsWith("Resume")) chapter else "Resume $chapter"
},
onClick = {
- openLibraryItem(
- item = novel.resumeItem,
- libraryViewModel = libraryViewModel,
- readerViewModel = readerViewModel,
- onCloseDrawer = onCloseDrawer
- )
+ onOpenLibraryItem(novel.resumeItem)
+ onCloseDrawer()
}
)
}
}
- if (libraryUiState.items.isEmpty()) {
+ if (isLibraryEmpty) {
item {
EmptyQuickAccessState()
}
@@ -164,40 +139,6 @@ fun LibraryDrawerContent(
}
}
-private fun openLibraryItem(
- item: LibraryItem,
- libraryViewModel: LibraryViewModel,
- readerViewModel: ReaderViewModel,
- onCloseDrawer: () -> Unit
-): Unit {
- val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url
- readerViewModel.loadContent(loadUrl, item.id)
- libraryViewModel.markAsCurrentlyReading(item.id)
- onCloseDrawer()
-}
-
-private fun openLatestUpdateItem(
- item: LibraryItem,
- libraryViewModel: LibraryViewModel,
- readerViewModel: ReaderViewModel,
- onCloseDrawer: () -> Unit
-): Unit {
- val baseTitle = item.baseTitle.ifBlank { item.title }
- if (item.baseNovelUrl.isBlank() || item.sourceName.isBlank()) {
- val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url
- readerViewModel.openChapterFromStart(loadUrl, item.id)
- libraryViewModel.markAsCurrentlyReading(item.id)
- onCloseDrawer()
- return
- }
-
- libraryViewModel.openNewChapter(baseTitle, item.baseNovelUrl, item.sourceName) { url, id ->
- readerViewModel.openChapterFromStart(url, id)
- libraryViewModel.markAsCurrentlyReading(id)
- onCloseDrawer()
- }
-}
-
@Composable
private fun ContinueReadingCard(
item: LibraryItem,
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt
index 503b19aa..aec835f8 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt
@@ -9,7 +9,7 @@ import io.aatricks.easyreader.data.model.libraryNovelKey
import io.aatricks.easyreader.data.model.resolvedChapterNumber
import io.aatricks.easyreader.data.model.seriesReadingStatus
-internal data class DrawerNovelEntry(
+data class DrawerNovelEntry(
val novelKey: String,
val displayTitle: String,
val resumeItem: LibraryItem,
@@ -20,7 +20,7 @@ internal data class DrawerNovelEntry(
val updateTimestamp: Long
)
-internal data class DrawerNovelSections(
+data class DrawerNovelSections(
val continueNovel: DrawerNovelEntry?,
val recentUpdates: List,
val recentNovels: List
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt
index 34b446cc..9f63609c 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt
@@ -102,6 +102,12 @@ internal fun ContentArea(
isManhwaByUrl || (content.getImageCount() > content.getTextCount() && content.getImageCount() > 2)
}
+ val stableKeys = remember(content) {
+ content.paragraphs.mapIndexed { idx, element ->
+ stableContentElementKey(content.url, idx, element)
+ }
+ }
+
// No init values — single restore path through LaunchedEffect below. Initial values would
// race with the LaunchedEffect and create the "two paths, one of them silently wrong" bug.
val listState = key(content.url) { rememberLazyListState() }
@@ -149,15 +155,23 @@ internal fun ContentArea(
}
}
- LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) {
- val currentIndex = if (uiState.isPagedMode) pagerState.currentPage else listState.firstVisibleItemIndex
- prefetchImages(currentIndex, content, requestedIndices) { url ->
- readerViewModel.prefetchVisibleImage(url, content.url)
+ // snapshotFlow instead of effect keys: reading firstVisibleItemIndex during composition
+ // recomposed this whole scope — and cancelled/relaunched the effect — on every item
+ // boundary crossed while scrolling. The captured `content` may be an older copy after a
+ // dimension rebuild, which is fine: prefetch only reads the element urls, identical
+ // across copies of the same chapter.
+ LaunchedEffect(content.url, uiState.isPagedMode) {
+ snapshotFlow {
+ if (uiState.isPagedMode) pagerState.currentPage else listState.firstVisibleItemIndex
+ }.collect { currentIndex ->
+ prefetchImages(currentIndex, content, requestedIndices) { url ->
+ readerViewModel.prefetchVisibleImage(url, content.url)
+ }
}
}
LaunchedEffect(content.url, uiState.seekTrigger) {
- runScrollRestore(content, uiState, listState, pagerState, readerViewModel)
+ runScrollRestore(content, listState, pagerState, readerViewModel, stableKeys)
}
// Detect genuine user drags on the LazyList. Programmatic scrollToItem calls do NOT
@@ -178,9 +192,7 @@ internal fun ContentArea(
LaunchedEffect(pagerState.currentPage) {
val totalItems = content.paragraphs.size
val currentItem = pagerState.currentPage
- val currentKey = content.paragraphs.getOrNull(currentItem)
- ?.let { stableContentElementKey(content.url, currentItem, it) }
- ?: ""
+ val currentKey = stableKeys.getOrNull(currentItem) ?: ""
readerViewModel.updateScrollPosition(
scrollOffset = currentItem.toFloat(),
@@ -206,7 +218,7 @@ internal fun ContentArea(
return@LifecycleEventObserver
}
- val snapshot = buildScrollSnapshot(listState, content) ?: return@LifecycleEventObserver
+ val snapshot = buildScrollSnapshot(listState, content, stableKeys) ?: return@LifecycleEventObserver
readerViewModel.updateScrollPosition(
scrollOffset = snapshot.scrollOffset,
maxScrollOffset = snapshot.maxScrollOffset,
@@ -236,7 +248,7 @@ internal fun ContentArea(
}
.conflate()
.collect {
- val snapshot = buildScrollSnapshot(listState, content) ?: return@collect
+ val snapshot = buildScrollSnapshot(listState, content, stableKeys) ?: return@collect
readerViewModel.updateScrollPosition(
scrollOffset = snapshot.scrollOffset,
maxScrollOffset = snapshot.maxScrollOffset,
@@ -386,10 +398,10 @@ internal fun ContentArea(
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
private suspend fun runScrollRestore(
content: ChapterContent,
- uiState: ReaderViewModel.ReaderUiState,
listState: LazyListState,
pagerState: androidx.compose.foundation.pager.PagerState,
readerViewModel: ReaderViewModel,
+ stableKeys: List,
) {
// ─── Unified restore path ───────────────────────────────────────────────
// Single entry point handles BOTH initial load and seek-bar drags. Resolution order:
@@ -407,7 +419,8 @@ private suspend fun runScrollRestore(
return
}
- val targetIndex = resolveRestoreIndex(content, uiState)
+ val uiState = readerViewModel.uiState.value
+ val targetIndex = resolveRestoreIndex(uiState, stableKeys)
.coerceIn(0, content.paragraphs.lastIndex)
val targetFraction = uiState.restoreOffsetFraction
.takeIf { it >= 0f }
@@ -524,29 +537,32 @@ private suspend fun awaitStableRestore(
}
private fun resolveRestoreIndex(
- content: ChapterContent,
- uiState: ReaderViewModel.ReaderUiState
+ uiState: ReaderViewModel.ReaderUiState,
+ stableKeys: List
): Int {
val key = uiState.restoreElementKey
if (key.isNotEmpty()) {
- content.paragraphs.forEachIndexed { idx, element ->
- if (stableContentElementKey(content.url, idx, element) == key) return idx
- }
+ val idx = stableKeys.indexOf(key)
+ if (idx >= 0) return idx
}
return uiState.scrollIndex
}
private fun computeVisiblePercent(listState: LazyListState, totalItems: Int): Float? {
- if (totalItems <= 0) return null
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
- val firstItem = visibleItems.firstOrNull() ?: return null
- if (firstItem.size <= 0) return null
+ val firstItem = visibleItems.firstOrNull()
+ if (totalItems <= 0 || firstItem == null || firstItem.size <= 0) {
+ return null
+ }
val viewportPx = layoutInfo.viewportSize.height.toFloat().coerceAtLeast(1f)
- val avgItemSizePx = visibleItems.map { it.size.toFloat() }
- .average()
- .toFloat()
- .coerceAtLeast(1f)
+ val avgItemSizePx = if (visibleItems.isEmpty()) 1f else {
+ var sum = 0f
+ for (i in 0 until visibleItems.size) {
+ sum += visibleItems[i].size
+ }
+ (sum / visibleItems.size).coerceAtLeast(1f)
+ }
val totalContentPx = totalItems.toFloat() * avgItemSizePx
val pixelsBeforeFirst = firstItem.index.toFloat() * avgItemSizePx
val currentPixelOffset = pixelsBeforeFirst + listState.firstVisibleItemScrollOffset.toFloat()
@@ -559,18 +575,21 @@ internal fun shouldRunPercentRestoreFallback(
targetFraction: Float?
): Boolean = !isPreciseRestore || targetFraction == null || targetFraction == 0f
-private fun buildScrollSnapshot(listState: LazyListState, content: ChapterContent): ReaderScrollSnapshot? {
- if (content.paragraphs.isEmpty()) return null
+private fun buildScrollSnapshot(
+ listState: LazyListState,
+ content: ChapterContent,
+ stableKeys: List
+): ReaderScrollSnapshot? {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
- val firstItem = visibleItems.firstOrNull() ?: return null
- if (firstItem.size <= 0) return null
+ val firstItem = visibleItems.firstOrNull()
+ if (content.paragraphs.isEmpty() || firstItem == null || firstItem.size <= 0) {
+ return null
+ }
val itemSize = firstItem.size.coerceAtLeast(1)
val offsetFraction = (listState.firstVisibleItemScrollOffset.toFloat() / itemSize.toFloat()).coerceIn(0f, 1f)
- val elementKey = content.paragraphs.getOrNull(firstItem.index)
- ?.let { stableContentElementKey(content.url, firstItem.index, it) }
- ?: ""
+ val elementKey = stableKeys.getOrNull(firstItem.index) ?: ""
// Pixel-weighted progress: stable across image-decode reflow that would otherwise
// drag percent down as items grow. Unmeasured items off-screen are estimated via the
@@ -578,10 +597,13 @@ private fun buildScrollSnapshot(listState: LazyListState, content: ChapterConten
// so the estimate is accurate enough to keep the seek bar from sliding backward.
val totalItems = layoutInfo.totalItemsCount.coerceAtLeast(1)
val viewportPx = layoutInfo.viewportSize.height.toFloat().coerceAtLeast(1f)
- val avgItemSizePx = visibleItems.map { it.size.toFloat() }
- .average()
- .toFloat()
- .coerceAtLeast(1f)
+ val avgItemSizePx = if (visibleItems.isEmpty()) 1f else {
+ var sum = 0f
+ for (i in 0 until visibleItems.size) {
+ sum += visibleItems[i].size
+ }
+ (sum / visibleItems.size).coerceAtLeast(1f)
+ }
val totalContentPx = totalItems.toFloat() * avgItemSizePx
val pixelsBeforeFirst = firstItem.index.toFloat() * avgItemSizePx
val currentPixelOffset = pixelsBeforeFirst + listState.firstVisibleItemScrollOffset.toFloat()
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt
index f0f261ad..6ecc42f9 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -146,46 +147,54 @@ internal fun rememberReaderNestedScrollConnection(
onNavigatePrevious: () -> Unit,
onNavigateNext: () -> Unit
): NestedScrollConnection {
+ val currentUiState by rememberUpdatedState(uiState)
+ val currentOnHideControls by rememberUpdatedState(onHideControls)
+ val currentOnUserInteraction by rememberUpdatedState(onUserInteraction)
+ val currentOnPullAmountChange by rememberUpdatedState(onPullAmountChange)
+ val currentOnNavigatePrevious by rememberUpdatedState(onNavigatePrevious)
+ val currentOnNavigateNext by rememberUpdatedState(onNavigateNext)
+
var pullAmount by remember { mutableFloatStateOf(0f) }
var handledUserScrollStart by remember { mutableStateOf(false) }
- return remember(content, uiState.isPagedMode, uiState.isRtl, pagerState.currentPage) {
+ return remember(content, pagerState, listState, threshold) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val state = currentUiState
if (source == NestedScrollSource.UserInput &&
shouldDispatchReaderScrollStart(available, handledUserScrollStart)
) {
- if (uiState.showControls) {
- onHideControls()
+ if (state.showControls) {
+ currentOnHideControls()
}
- onUserInteraction()
+ currentOnUserInteraction()
handledUserScrollStart = true
}
- if (uiState.isPagedMode) {
+ if (state.isPagedMode) {
if (pullAmount > 0 && available.x < 0) {
val consumed = available.x.coerceAtLeast(-pullAmount)
pullAmount += consumed
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(consumed, 0f)
}
if (pullAmount < 0 && available.x > 0) {
val consumed = available.x.coerceAtMost(-pullAmount)
pullAmount += consumed
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(consumed, 0f)
}
} else {
if (pullAmount > 0 && available.y < 0) {
val consumed = available.y.coerceAtLeast(-pullAmount)
pullAmount += consumed
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(0f, consumed)
}
if (pullAmount < 0 && available.y > 0) {
val consumed = available.y.coerceAtMost(-pullAmount)
pullAmount += consumed
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(0f, consumed)
}
}
@@ -198,77 +207,79 @@ internal fun rememberReaderNestedScrollConnection(
source: NestedScrollSource
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
+ val state = currentUiState
- if (uiState.isPagedMode) {
+ if (state.isPagedMode) {
val isAtStart = pagerState.currentPage == 0
val isAtEnd = pagerState.currentPage == content.paragraphs.size - 1
val isAtAnyEdge = isAtStart || isAtEnd
- if (uiState.isRtl) {
- if (available.x < 0 && isAtStart && uiState.canNavigatePrevious) {
+ if (state.isRtl) {
+ if (available.x < 0 && isAtStart && state.canNavigatePrevious) {
pullAmount += available.x * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(available.x, 0f)
- } else if (available.x > 0 && isAtEnd && uiState.canNavigateNext) {
+ } else if (available.x > 0 && isAtEnd && state.canNavigateNext) {
pullAmount += available.x * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(available.x, 0f)
}
} else {
- if (available.x > 0 && isAtStart && uiState.canNavigatePrevious) {
+ if (available.x > 0 && isAtStart && state.canNavigatePrevious) {
pullAmount += available.x * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(available.x, 0f)
- } else if (available.x < 0 && isAtEnd && uiState.canNavigateNext) {
+ } else if (available.x < 0 && isAtEnd && state.canNavigateNext) {
pullAmount += available.x * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(available.x, 0f)
}
}
if (pullAmount != 0f && !isAtAnyEdge) {
pullAmount = 0f
- onPullAmountChange(0f)
+ currentOnPullAmountChange(0f)
}
} else {
val isAtTop = !listState.canScrollBackward
val isAtBottom = !listState.canScrollForward
- if (available.y > 0 && isAtTop && uiState.canNavigatePrevious) {
+ if (available.y > 0 && isAtTop && state.canNavigatePrevious) {
pullAmount += available.y * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(0f, available.y)
- } else if (available.y < 0 && isAtBottom && uiState.canNavigateNext) {
+ } else if (available.y < 0 && isAtBottom && state.canNavigateNext) {
pullAmount += available.y * 0.5f
- onPullAmountChange(pullAmount)
+ currentOnPullAmountChange(pullAmount)
return Offset(0f, available.y)
}
if (pullAmount != 0f && !isAtTop && !isAtBottom) {
pullAmount = 0f
- onPullAmountChange(0f)
+ currentOnPullAmountChange(0f)
}
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
+ val state = currentUiState
if (abs(pullAmount) >= threshold) {
- val isPrevious = if (uiState.isPagedMode) {
- if (uiState.isRtl) pullAmount < 0 else pullAmount > 0
+ val isPrevious = if (state.isPagedMode) {
+ if (state.isRtl) pullAmount < 0 else pullAmount > 0
} else {
pullAmount > 0
}
if (isPrevious) {
- onNavigatePrevious()
+ currentOnNavigatePrevious()
} else {
- onNavigateNext()
+ currentOnNavigateNext()
}
}
pullAmount = 0f
handledUserScrollStart = false
- onPullAmountChange(0f)
+ currentOnPullAmountChange(0f)
return Velocity.Zero
}
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt
index 23a028e1..f7b3755f 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -442,7 +443,12 @@ internal fun ScrollingReaderView(
}
is ContentElement.Image -> {
- val resolved = readerViewModel.resolvedImageDimensions[element.url]
+ // Per-URL State: another image resolving its dimensions must not invalidate
+ // this item's scope (a map-wide read here caused every visible image to
+ // recompose on each decode during scroll).
+ val resolved by remember(element.url) {
+ readerViewModel.imageDimensionState(element.url)
+ }
val imgW = resolved?.first ?: element.width
val imgH = resolved?.second ?: element.height
val screenWidthPx = with(LocalDensity.current) {
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt
index ee720c44..a6801f60 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt
@@ -165,6 +165,11 @@ fun ReaderScreen(
ModalNavigationDrawer(
drawerState = drawerState,
modifier = modifier,
+ // Edge-swipe opens the drawer in continuous (manhwa/webtoon) scrolling, where the
+ // horizontal edge drag never conflicts with the vertical scroll. In paged mode the left
+ // edge belongs to horizontal page-turns, so only allow the swipe once the drawer is
+ // already open (to close it) — it's still reachable there via the toolbar/back button.
+ gesturesEnabled = drawerState.isOpen || !uiState.isPagedMode,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.width(320.dp)
@@ -174,16 +179,41 @@ fun ReaderScreen(
drawerState.targetValue == DrawerValue.Open
if (drawerIsOpeningOrOpen) {
val libraryViewModel = libraryViewModelProvider()
- LaunchedEffect(libraryViewModel) {
- libraryViewModel.reconcileDownloadedItemsOnDemand()
- }
+ val drawerUi by libraryViewModel.drawerUiState.collectAsState()
LibraryDrawerContent(
- libraryViewModel = libraryViewModel,
- readerViewModel = readerViewModel,
- navController = navController,
+ drawerSections = drawerUi.sections,
+ isLibraryEmpty = drawerUi.isLibraryEmpty,
onOpenFilePicker = onOpenFilePicker,
onCloseDrawer = {
scope.launch { drawerState.close() }
+ },
+ onLibraryClick = {
+ navController.navigate(LibraryRoute) {
+ launchSingleTop = true
+ }
+ },
+ onDiscoverClick = {
+ navController.navigate(io.aatricks.easyreader.ui.ExploreRoute) {
+ launchSingleTop = true
+ }
+ },
+ onOpenLibraryItem = { item ->
+ val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url
+ readerViewModel.loadContent(loadUrl, item.id)
+ libraryViewModel.markAsCurrentlyReading(item.id)
+ },
+ onOpenLatestUpdate = { item ->
+ val baseTitle = item.baseTitle.ifBlank { item.title }
+ if (item.baseNovelUrl.isBlank() || item.sourceName.isBlank()) {
+ val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url
+ readerViewModel.openChapterFromStart(loadUrl, item.id)
+ libraryViewModel.markAsCurrentlyReading(item.id)
+ } else {
+ libraryViewModel.openNewChapter(baseTitle, item.baseNovelUrl, item.sourceName) { url, id ->
+ readerViewModel.openChapterFromStart(url, id)
+ libraryViewModel.markAsCurrentlyReading(id)
+ }
+ }
}
)
}
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt
index c81efa44..a0d6ff3b 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt
@@ -12,7 +12,7 @@ import kotlinx.coroutines.launch
abstract class BaseViewModel(initialState: S) : ViewModel() {
protected val _uiState = MutableStateFlow(initialState)
- val uiState: StateFlow = _uiState.asStateFlow()
+ open val uiState: StateFlow = _uiState.asStateFlow()
protected fun updateState(block: (S) -> S): Unit {
_uiState.update(block)
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt
index e50a2bc8..313310df 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt
@@ -1,13 +1,15 @@
package io.aatricks.easyreader.ui.viewmodel
-import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import android.util.Log
-import io.aatricks.easyreader.util.rethrowCancellation
+import java.util.concurrent.ConcurrentHashMap
/**
* Owns the resolved-image-dimension pipeline extracted from ReaderViewModel.
@@ -21,12 +23,14 @@ class ImageDimensionManager(
private val imageDimensionCache: ImageDimensionCacheRepository,
private val applyContentDimensions: (Map>) -> Unit,
) {
- // Resolved intrinsic dimensions keyed by image URL, as fine-grained Compose state. A
- // ReaderImageView reads its own entry, so a write only recomposes that one image — and an
- // item scrolled away and back is sized correctly on its FIRST composition (no collapse to the
- // loading placeholder + relayout). This is what keeps fast up/down dragging smooth; the
- // debounced content rebuild stays only for persistence / restore math.
- val resolvedImageDimensions = mutableStateMapOf>()
+ // Resolved intrinsic dimensions keyed by image URL, one MutableState per URL. A single
+ // SnapshotStateMap would NOT give per-image granularity — its read tracking is per-map, so
+ // any write invalidates every composed reader. With one State per URL, a first-decode write
+ // recomposes only that image — and an item scrolled away and back is sized correctly on its
+ // FIRST composition (no collapse to the loading placeholder + relayout), which is what keeps
+ // fast up/down dragging smooth. The debounced content rebuild stays only for persistence /
+ // restore math.
+ private val dimensionStates = ConcurrentHashMap?>>()
private val pendingImageDimensions = LinkedHashMap>()
private val contentDimUpdates = LinkedHashMap>()
@@ -39,11 +43,26 @@ class ImageDimensionManager(
private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L
}
+ /**
+ * Composable-friendly per-URL dimension state: a write invalidates only readers of THIS url.
+ * computeIfAbsent (not getOrPut, which is non-atomic on ConcurrentHashMap) guarantees a
+ * single State instance per url, so subscription and later writes always meet.
+ */
+ fun dimensionState(imageUrl: String): State?> =
+ dimensionStates.computeIfAbsent(imageUrl) { mutableStateOf(null) }
+
fun persistImageDimensions(imageUrl: String, width: Int, height: Int) {
if (imageUrl.isBlank() || width <= 0 || height <= 0) return
- resolvedImageDimensions[imageUrl] = width to height
- pendingImageDimensions[imageUrl] = width to height
- contentDimUpdates[imageUrl] = width to height
+ val dims = width to height
+ // A recycled item re-entering composition re-fires AsyncImage.onSuccess with the same
+ // dimensions (memory-cache hits included), so during fast up/down scrolling this is
+ // called once per re-entry per image. Skip the whole cascade — snapshot write, Room
+ // rewrite, and apply-job churn — when nothing actually changed.
+ val state = dimensionStates.computeIfAbsent(imageUrl) { mutableStateOf(null) }
+ if (state.value == dims) return
+ state.value = dims
+ pendingImageDimensions[imageUrl] = dims
+ contentDimUpdates[imageUrl] = dims
scheduleDimensionDbFlush()
scheduleContentDimApply()
}
@@ -54,7 +73,7 @@ class ImageDimensionManager(
dimensionFlushJob = scope.launch {
delay(IMAGE_DIMENSION_FLUSH_DELAY_MS)
while (pendingImageDimensions.isNotEmpty()) {
- flushPendingImageDimensions()
+ if (!flushPendingImageDimensions()) break
}
}
}
@@ -77,25 +96,43 @@ class ImageDimensionManager(
}
}
- private suspend fun flushPendingImageDimensions() {
+ /** @return false when the write failed; the batch is re-queued for a later flush. */
+ private suspend fun flushPendingImageDimensions(): Boolean {
val updates = pendingImageDimensions.toMap()
pendingImageDimensions.clear()
- if (updates.isEmpty()) return
- runCatching {
- imageDimensionCache.persistAll(updates.map { (url, dimensions) ->
- Triple(url, dimensions.first, dimensions.second)
- })
- }.rethrowCancellation().onFailure { e ->
- Log.e(TAG, "Failed to flush pending image dimensions", e)
+ if (updates.isEmpty()) return true
+ val persisted = imageDimensionCache.persistAll(updates.map { (url, dimensions) ->
+ Triple(url, dimensions.first, dimensions.second)
+ })
+ if (!persisted) {
+ // Re-queue: the duplicate-persist guard means no future onSuccess will re-enqueue
+ // these dims itself, so dropping the batch here would lose them for the session.
+ // putIfAbsent keeps any newer value that arrived during the failed write.
+ updates.forEach { (url, dims) -> pendingImageDimensions.putIfAbsent(url, dims) }
+ Log.e(TAG, "Failed to flush pending image dimensions; batch re-queued")
}
+ return persisted
}
- // 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()
+ /**
+ * Called after a new chapter's content replaces the old one. Drops per-URL state for images
+ * that are not part of the new chapter — without this the manager grows unboundedly for the
+ * life of the (Activity-scoped) ReaderViewModel, one entry per image ever displayed. Keeping
+ * the new chapter's own urls means a same-chapter reload keeps its dims. The db-flush job /
+ * pending map are intentionally left running so an in-flight persist of already-resolved
+ * dimensions still completes (the on-disk cache is cross-chapter by design).
+ */
+ fun pruneForChapter(currentImageUrls: Set) {
+ dimensionStates.keys.retainAll(currentImageUrls)
+ contentDimUpdates.keys.retainAll(currentImageUrls)
+ // Re-enqueue the survivors' resolved dims: a url shared with the previous chapter can
+ // hold dimensions here while the new chapter's element still lacks them (the Room seed
+ // raced the 100ms flush), and the duplicate-persist guard would otherwise block the
+ // content rebuild from ever healing that element.
+ dimensionStates.forEach { (url, state) ->
+ val dims = state.value ?: return@forEach
+ contentDimUpdates.putIfAbsent(url, dims)
+ }
+ if (contentDimUpdates.isNotEmpty()) scheduleContentDimApply()
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt
index 42d15384..cc8b7c86 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt
@@ -23,6 +23,9 @@ import kotlinx.coroutines.Job
import io.aatricks.easyreader.util.rethrowCancellation
import javax.inject.Inject
import android.util.Log
+import io.aatricks.easyreader.ui.screens.DrawerNovelSections
+import io.aatricks.easyreader.ui.screens.buildDrawerNovelSections
+import kotlinx.coroutines.Dispatchers
@HiltViewModel
class LibraryViewModel @Inject constructor(
@@ -32,7 +35,6 @@ class LibraryViewModel @Inject constructor(
private val downloadQueue: ChapterDownloadQueue,
private val downloadStatusReconciler: DownloadStatusReconciler
) : BaseViewModel(LibraryUiState()) {
-
private val TAG = "LibraryViewModel"
private val filters = LibraryFilters()
@@ -68,13 +70,93 @@ class LibraryViewModel @Inject constructor(
init {
_collapsedSources.value = repository.loadCollapsedSources()
- observeLibraryChanges()
}
fun reconcileDownloadedItemsOnDemand(): Unit {
downloadStates.reconcileDownloadedItemsOnDemand()
}
+ override val uiState: StateFlow = combine(
+ combine(
+ repository.libraryItems,
+ selectionManager.selectedItems,
+ _collapsedSources,
+ selectionManager.selectionModeEnabled
+ ) { items, selected, collapsed, selectionModeEnabled ->
+ Triple(items, selected, collapsed) to selectionModeEnabled
+ },
+ combine(
+ downloadStates.chapterCacheStates,
+ deletionCoordinator.pendingDeletion,
+ filters.statusFilter
+ ) { c, p, s ->
+ Triple(c, p, s)
+ },
+ combine(
+ filters.searchQuery,
+ filters.contentTypeFilter,
+ filters.sortMode
+ ) { query, filter, sort ->
+ Triple(query, filter, sort)
+ },
+ _uiState
+ ) { repoState, cachePending, filterParams, manualUiState ->
+ val (repoData, selectionModeEnabled) = repoState
+ val (rawItems, selectedIds, collapsedSources) = repoData
+ val (cacheStates, pendingIds, statusFilter) = cachePending
+ val (query, filter, sort) = filterParams
+
+ val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds }
+ val filteredItems = filters.apply(items, query, filter, sort, statusFilter)
+
+ LibraryUiState(
+ items = items,
+ filteredItems = filteredItems,
+ groupedItems = repository.getGroupedByTitle(filteredItems),
+ groupedBySource = repository.getGroupedBySourceAndTitle(filteredItems),
+ collapsedSources = collapsedSources,
+ isSelectionMode = selectionModeEnabled || selectedIds.isNotEmpty(),
+ selectedIds = selectedIds,
+ selectedCount = selectedIds.size,
+ isEmpty = items.isEmpty(),
+ currentlyReading = items.find { it.isCurrentlyReading },
+ chapterCacheStates = cacheStates,
+ isLoading = manualUiState.isLoading,
+ error = manualUiState.error
+ )
+ }
+ .flowOn(defaultDispatcher)
+ .stateIn(
+ scope = viewModelScope,
+ // Stop the moment no screen observes (library screen / chapter-list sheet). The heavy
+ // getGroupedByTitle/BySource run only while one is on-screen; the last value is retained
+ // (default replayExpiration) so re-entry is warm with no empty flash.
+ started = if (isUnderTest) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(0),
+ initialValue = LibraryUiState()
+ )
+
+ /**
+ * Lean state for the reader's library drawer. The drawer needs ONLY the quick-access sections
+ * and an empty flag, so it deliberately does NOT go through [uiState]'s whole-library
+ * getGroupedByTitle/getGroupedBySourceAndTitle aggregation: opening the drawer over a large
+ * library would otherwise burst-allocate those maps on Default, and the resulting GC could
+ * evict decoded reader bitmaps (re-decode stutter on resume). Recomputed off-main, stopped the
+ * instant the drawer closes.
+ */
+ val drawerUiState: StateFlow = combine(
+ repository.libraryItems,
+ deletionCoordinator.pendingDeletion
+ ) { rawItems, pendingIds ->
+ val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds }
+ DrawerUiState(buildDrawerNovelSections(items), items.isEmpty())
+ }
+ .flowOn(defaultDispatcher)
+ .stateIn(
+ scope = viewModelScope,
+ started = if (isUnderTest) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(0),
+ initialValue = DrawerUiState(DrawerNovelSections(null, emptyList(), emptyList()), true)
+ )
+
data class LibraryUiState(
val items: List = emptyList(),
val filteredItems: List = emptyList(),
@@ -91,57 +173,10 @@ class LibraryViewModel @Inject constructor(
val chapterCacheStates: Map = emptyMap()
)
- private fun observeLibraryChanges(): Unit {
- viewModelScope.launch {
- val repoFlow = combine(
- repository.libraryItems,
- selectionManager.selectedItems,
- _collapsedSources,
- selectionManager.selectionModeEnabled
- ) { items, selected, collapsed, selectionModeEnabled ->
- Triple(items, selected, collapsed) to selectionModeEnabled
- }
-
- val cacheAndPending = combine(
- downloadStates.chapterCacheStates,
- deletionCoordinator.pendingDeletion,
- filters.statusFilter
- ) { c, p, s ->
- Triple(c, p, s)
- }
-
- combine(
- repoFlow,
- cacheAndPending,
- filters.searchQuery,
- filters.contentTypeFilter,
- filters.sortMode
- ) { repoState, cachePending, query, filter, sort ->
- val (repoData, selectionModeEnabled) = repoState
- val (rawItems, selectedIds, collapsedSources) = repoData
- val (cacheStates, pendingIds, statusFilter) = cachePending
-
- val items = if (pendingIds.isEmpty()) rawItems else rawItems.filterNot { it.id in pendingIds }
- val filteredItems = filters.apply(items, query, filter, sort, statusFilter)
-
- LibraryUiState(
- items = items,
- filteredItems = filteredItems,
- groupedItems = repository.getGroupedByTitle(filteredItems),
- groupedBySource = repository.getGroupedBySourceAndTitle(filteredItems),
- collapsedSources = collapsedSources,
- isSelectionMode = selectionModeEnabled || selectedIds.isNotEmpty(),
- selectedIds = selectedIds,
- selectedCount = selectedIds.size,
- isEmpty = items.isEmpty(),
- currentlyReading = items.find { it.isCurrentlyReading },
- chapterCacheStates = cacheStates
- )
- }.collect { newState ->
- updateState { newState }
- }
- }
- }
+ data class DrawerUiState(
+ val sections: DrawerNovelSections,
+ val isLibraryEmpty: Boolean
+ )
@@ -592,6 +627,10 @@ class LibraryViewModel @Inject constructor(
}
}
+ companion object {
+ var defaultDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default
+ var isUnderTest: Boolean = false
+ }
}
internal fun selectLatestChapter(chapters: List): ChapterInfo? {
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt
index d85ed4a3..741e7275 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt
@@ -80,6 +80,18 @@ class ReaderProgressController(
private var lastReportedProgress: Float = -1f
private var progressUpdateJob: Job? = null
+ private var lastUpdateTime: Long = 0L
+ private var pendingUpdate: PendingProgressUpdate? = null
+
+ private data class PendingProgressUpdate(
+ val progress: Int,
+ val scrollPosition: Float,
+ val index: Int,
+ val elementKey: String,
+ val offsetFraction: Float,
+ val content: ChapterContent?,
+ val forcePersist: Boolean
+ )
companion object {
private const val TAG = "ReaderProgress"
@@ -460,21 +472,44 @@ class ReaderProgressController(
return
}
- progressUpdateJob?.cancel()
- progressUpdateJob = scope.launch {
- delay(100)
- if (progressInt >= 0) {
- updateReadingProgress(
- progress = progressInt,
- scrollPosition = progress,
- index = index,
- elementKey = elementKey,
- offsetFraction = effectiveFraction,
- content = content,
- forcePersist = isTerminal
- )
+ lastUpdateTime = System.currentTimeMillis()
+ pendingUpdate = PendingProgressUpdate(
+ progress = progressInt,
+ scrollPosition = progress,
+ index = index,
+ elementKey = elementKey,
+ offsetFraction = effectiveFraction,
+ content = content,
+ forcePersist = isTerminal
+ )
+
+ if (progressUpdateJob?.isActive != true) {
+ progressUpdateJob = scope.launch {
+ var update = pendingUpdate
+ if (update != null) {
+ val now = System.currentTimeMillis()
+ val elapsed = now - lastUpdateTime
+ if (elapsed < 100) {
+ delay(100 - elapsed)
+ }
+ update = pendingUpdate
+ if (update != null) {
+ if (update.progress >= 0) {
+ updateReadingProgress(
+ progress = update.progress,
+ scrollPosition = update.scrollPosition,
+ index = update.index,
+ elementKey = update.elementKey,
+ offsetFraction = update.offsetFraction,
+ content = update.content,
+ forcePersist = update.forcePersist
+ )
+ }
+ pendingUpdate = null
+ }
+ }
+ lastRawScrollOffset = scrollOffset
}
- lastRawScrollOffset = scrollOffset
}
}
@@ -546,10 +581,12 @@ class ReaderProgressController(
lastReportedFractionMillis = -1
lastReportedProgress = -1f
progressUpdateJob?.cancel()
+ pendingUpdate = null
}
fun cancelProgressUpdate() {
progressUpdateJob?.cancel()
+ pendingUpdate = null
}
}
diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt
index b943162e..46f335f5 100644
--- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt
+++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt
@@ -73,18 +73,20 @@ class ReaderViewModel @Inject constructor(
progressController.beginRestore()
}
- // Resolved intrinsic dimensions keyed by image URL, as fine-grained Compose state. A
- // ReaderImageView reads its own entry, so a write only recomposes that one image — and an
- // item scrolled away and back is sized correctly on its FIRST composition (no collapse to the
- // loading placeholder + relayout). This is what keeps fast up/down dragging smooth; the
- // debounced `content` rebuild below stays only for persistence / restore math.
+ // Resolved intrinsic dimensions keyed by image URL, one Compose State per URL. A
+ // ReaderImageView subscribes to its own url's State, so a write only recomposes that one
+ // image — and an item scrolled away and back is sized correctly on its FIRST composition
+ // (no collapse to the loading placeholder + relayout). This is what keeps fast up/down
+ // dragging smooth; the debounced `content` rebuild below stays only for persistence /
+ // restore math.
private val imageDimensionManager = ImageDimensionManager(
scope = viewModelScope,
imageDimensionCache = imageDimensionCache,
applyContentDimensions = ::updateCurrentContentImageDimensions,
)
- val resolvedImageDimensions get() = imageDimensionManager.resolvedImageDimensions
+ fun imageDimensionState(imageUrl: String): androidx.compose.runtime.State?> =
+ imageDimensionManager.dimensionState(imageUrl)
fun persistImageDimensions(imageUrl: String, width: Int, height: Int) =
imageDimensionManager.persistImageDimensions(imageUrl, width, height)
@@ -566,7 +568,6 @@ class ReaderViewModel @Inject constructor(
preCalculatedTextCount = result.textCount,
preCalculatedImageCount = result.imageCount
)
-
val libraryItem = effectiveId?.let { libraryRepository.getItemById(it) }
val baseTitle = getBaseTitle(content, libraryItem)
val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" }
@@ -631,6 +632,12 @@ class ReaderViewModel @Inject constructor(
}
syncProgressState(initialPosition)
+ // Prune only AFTER the new content is committed to uiState: during the (suspending)
+ // load above the old chapter is still composed, and pruning it early would strip its
+ // shared dimensions mid-display while late decodes re-inserted just-pruned entries.
+ // A failed load never reaches this line, so an on-screen chapter is never pruned.
+ imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet())
+
updateNavigationUrls()
maybeWarmNextChapter(_uiState.value.content?.nextChapterUrl)
@@ -901,6 +908,9 @@ class ReaderViewModel @Inject constructor(
}
syncProgressState(initialPosition)
+ // After the content swap, for the same reasons as in handleLoadSuccess.
+ imageDimensionManager.pruneForChapter(content.getAllImageUrls().toSet())
+
preferencesManager.batchUpdateLastRead(content.url, effectiveLibraryItemId)
effectiveLibraryItemId?.let { id ->
@@ -1090,15 +1100,6 @@ class ReaderViewModel @Inject constructor(
}
}
- fun resetState(): Unit {
- closeContent(_uiState.value.content)
- _uiState.value = ReaderUiState()
- progressController.resetState()
- isExplicitNavigation = false
- lastRawScrollOffset = -1f
- imageDimensionManager.reset()
- }
-
private fun closeContent(content: ChapterContent?) {
(content?.paragraphs as? java.io.Closeable)?.close()
}
diff --git a/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt b/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt
index afd08331..86783a74 100644
--- a/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt
+++ b/app/src/main/java/io/aatricks/easyreader/util/FileSizeUtils.kt
@@ -28,7 +28,7 @@ object FileSizeUtils {
return size
}
- fun trimDirectoryToSize(dir: File, maxBytes: Long): Long {
+ fun trimDirectoryToSize(dir: File, maxBytes: Long, onDelete: (File) -> Unit = {}): Long {
if (!dir.exists()) return 0L
val files = dir.walkTopDown()
.filter { it.isFile && !it.name.endsWith(".tmp") }
@@ -41,6 +41,7 @@ object FileSizeUtils {
val length = file.length()
if (file.delete()) {
total -= length
+ onDelete(file)
}
}
return total
diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt
index 05ecc665..054a5c2c 100644
--- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt
+++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt
@@ -98,6 +98,75 @@ class ImageCacheTest {
assertEquals(null, result)
}
+ @Test
+ fun `getLikelyMediaState memoizes until invalidated`() {
+ val url = "https://example.com/memo.jpg"
+ assertEquals("missing", imageCache.getLikelyMediaState(url))
+
+ // File appears on disk behind the memo's back: the memoized "missing" is served...
+ val file = File(cacheDir, CacheKeyUtils.keyFor(url)).apply { writeBytes(validJpegBytes()) }
+ assertEquals("missing", imageCache.getLikelyMediaState(url))
+
+ // ...until the url is invalidated (as every production write path does).
+ imageCache.invalidateMediaState(url)
+ val state = imageCache.getLikelyMediaState(url)
+ assertTrue(state.startsWith(file.absolutePath))
+ }
+
+ @Test
+ fun `deleteCachedMediaFiles refreshes media state`() {
+ val url = "https://example.com/delete-refresh.jpg"
+ File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes())
+ assertNotEquals("missing", imageCache.getLikelyMediaState(url))
+
+ imageCache.deleteCachedMediaFiles(url)
+
+ assertEquals("missing", imageCache.getLikelyMediaState(url))
+ }
+
+ @Test
+ fun `clearAll and trimToSize drop the media state memo`() {
+ val url = "https://example.com/clear-refresh.jpg"
+ File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes())
+ assertNotEquals("missing", imageCache.getLikelyMediaState(url))
+
+ imageCache.clearAll()
+ assertEquals("missing", imageCache.getLikelyMediaState(url))
+
+ File(cacheDir, CacheKeyUtils.keyFor(url)).writeBytes(validJpegBytes())
+ imageCache.invalidateMediaState(url)
+ assertNotEquals("missing", imageCache.getLikelyMediaState(url))
+ imageCache.trimToSize(0)
+ assertEquals("missing", imageCache.getLikelyMediaState(url))
+ }
+
+ @Test
+ fun `trimToSize keeps the memo when nothing is deleted`() {
+ val url = "https://example.com/trim-noop.jpg"
+ val file = File(cacheDir, CacheKeyUtils.keyFor(url)).apply { writeBytes(validJpegBytes()) }
+ val memoized = imageCache.getLikelyMediaState(url)
+
+ // Delete behind the memo's back, then trim with a budget that deletes nothing:
+ // the memo must survive (trim runs every ~30s during prefetch — wiping it on
+ // no-op trims would defeat the memoization).
+ file.delete()
+ imageCache.trimToSize(Long.MAX_VALUE)
+
+ assertEquals(memoized, imageCache.getLikelyMediaState(url))
+ }
+
+ @Test
+ fun `promoteToDownloads refreshes media state to the downloads path`() {
+ val url = "https://example.com/promote-refresh.jpg"
+ val key = CacheKeyUtils.keyFor(url)
+ File(cacheDir, key).writeBytes(validJpegBytes())
+ assertTrue(imageCache.getLikelyMediaState(url).startsWith(File(cacheDir, key).absolutePath))
+
+ imageCache.promoteToDownloads(url)
+
+ assertTrue(imageCache.getLikelyMediaState(url).startsWith(File(downloadsDir, key).absolutePath))
+ }
+
@Test
fun `promoteToDownloads replaces invalid existing download target`() {
val url = "https://example.com/promote.jpg"
diff --git a/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt b/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt
new file mode 100644
index 00000000..3747db4b
--- /dev/null
+++ b/app/src/test/java/io/aatricks/easyreader/e2e/ReaderEndToEndTest.kt
@@ -0,0 +1,949 @@
+package io.aatricks.easyreader.e2e
+
+import android.content.Context
+import androidx.compose.ui.geometry.Offset
+import androidx.test.core.app.ApplicationProvider
+import io.aatricks.easyreader.data.local.PreferencesManager
+import io.aatricks.easyreader.data.local.ReaderSettingsSnapshot
+import io.aatricks.easyreader.data.model.ChapterContent
+import io.aatricks.easyreader.data.model.ChapterInfo
+import io.aatricks.easyreader.data.model.ContentElement
+import io.aatricks.easyreader.data.model.ContentResult
+import io.aatricks.easyreader.data.model.ContentType
+import io.aatricks.easyreader.data.model.FRACTION_UNKNOWN
+import io.aatricks.easyreader.data.model.LibraryItem
+import io.aatricks.easyreader.data.model.PrefetchResult
+import io.aatricks.easyreader.data.model.PrefetchMode
+import io.aatricks.easyreader.data.model.ReaderTheme
+import io.aatricks.easyreader.data.model.ReadingMode
+import io.aatricks.easyreader.data.repository.ChapterListCache
+import io.aatricks.easyreader.data.repository.ContentRepository
+import io.aatricks.easyreader.data.repository.ExploreRepository
+import io.aatricks.easyreader.data.repository.LibraryRepository
+import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository
+import io.aatricks.easyreader.ui.screens.shouldDispatchReaderScrollStart
+import io.aatricks.easyreader.ui.screens.buildDrawerNovelSections
+import io.aatricks.easyreader.ui.screens.countDistinctNovelTitles
+import io.aatricks.easyreader.ui.screens.shouldRunPercentRestoreFallback
+import io.aatricks.easyreader.ui.theme.AccentTheme
+import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel
+import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel.ReaderUiState
+import io.aatricks.easyreader.ui.viewmodel.stableContentElementKey
+import io.aatricks.easyreader.ui.viewmodel.ReaderProgressController.Companion.PAGED_POSITION_ITEM_SIZE_PX
+import io.aatricks.easyreader.util.FieldUpdate
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.test.resetMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+private const val NUM_ZERO = 0
+private const val NUM_ONE = 1
+private const val NUM_TWO = 2
+private const val NUM_THREE = 3
+private const val NUM_FOUR = 4
+private const val NUM_FIVE = 5
+private const val NUM_SIX = 6
+private const val NUM_SEVEN = 7
+private const val NUM_EIGHT = 8
+private const val NUM_NINE = 9
+private const val NUM_TEN = 10
+private const val NUM_SEVENTEEN = 17
+private const val NUM_TWENTY = 20
+private const val NUM_THIRTY = 30
+private const val NUM_FIFTY = 50
+private const val NUM_FIFTY_FIVE = 55
+private const val NUM_SEVENTY_FIVE = 75
+private const val NUM_EIGHTY = 80
+private const val NUM_EIGHTY_NINE = 89
+private const val NUM_ONE_HUNDRED = 100
+private const val NUM_FIVE_HUNDRED = 500
+private const val NUM_ONE_THOUSAND = 1000L
+private const val NUM_TWO_THOUSAND = 2000L
+private const val NUM_THREE_THOUSAND = 3000L
+
+private const val FLOAT_ZERO = 0f
+private const val FLOAT_ONE = 1f
+private const val FLOAT_ZERO_THREE = 0.3f
+private const val FLOAT_ZERO_FIVE = 0.5f
+private const val FLOAT_ZERO_SIX = 0.6f
+private const val FLOAT_ONE_POINT_FIVE = 1.5f
+private const val FLOAT_FONT_SIZE_DEFAULT = 18f
+private const val FLOAT_THRESHOLD_VAL = 80f
+private const val FLOAT_DELTA_VAL_12 = 12f
+private const val FLOAT_DELTA_VAL_3 = 3f
+private const val FLOAT_DELTA_VAL_6 = 6f
+
+private const val NUM_MARGINS_DEFAULT = 16
+private const val FLOAT_SPACING_DEFAULT = 1.0f
+
+@RunWith(RobolectricTestRunner::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReaderEndToEndTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Mock lateinit var contentRepository: ContentRepository
+ @Mock lateinit var libraryRepository: LibraryRepository
+ @Mock lateinit var exploreRepository: ExploreRepository
+ @Mock lateinit var preferencesManager: PreferencesManager
+ @Mock lateinit var chapterListCache: ChapterListCache
+
+ private lateinit var viewModel: ReaderViewModel
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ Dispatchers.setMain(testDispatcher)
+
+ whenever(preferencesManager.fontSize).thenReturn(FLOAT_FONT_SIZE_DEFAULT)
+ whenever(preferencesManager.lineHeight).thenReturn(FLOAT_ONE_POINT_FIVE)
+ whenever(preferencesManager.fontFamily).thenReturn("Default")
+ whenever(preferencesManager.readerTheme).thenReturn(ReaderTheme.DARK.name)
+ whenever(preferencesManager.margins).thenReturn(NUM_MARGINS_DEFAULT)
+ whenever(preferencesManager.paragraphSpacing).thenReturn(FLOAT_SPACING_DEFAULT)
+ whenever(preferencesManager.readerSettings).thenReturn(
+ MutableStateFlow(
+ ReaderSettingsSnapshot(
+ fontSize = FLOAT_FONT_SIZE_DEFAULT,
+ lineHeight = FLOAT_ONE_POINT_FIVE,
+ fontFamily = "Default",
+ margins = NUM_MARGINS_DEFAULT,
+ paragraphSpacing = FLOAT_SPACING_DEFAULT,
+ readerTheme = ReaderTheme.DARK.name,
+ accentTheme = AccentTheme.MOSS.name
+ )
+ )
+ )
+
+ runTest {
+ whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(emptyList()))
+ whenever(libraryRepository.getCurrentlyReading()).thenReturn(null)
+ whenever(libraryRepository.getItemByUrl(any())).thenReturn(null)
+ whenever(libraryRepository.markAsCurrentlyReading(any())).thenReturn(true)
+ whenever(libraryRepository.updateProgressExplicit(
+ any(), any(), any(), any(), any(), any(), any(), any()
+ )).thenReturn(true)
+
+ whenever(contentRepository.inspectCache(any())).thenAnswer { invocation ->
+ PrefetchResult(
+ url = invocation.arguments[0] as String,
+ htmlCached = false,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = false
+ )
+ }
+ whenever(contentRepository.prefetch(any(), any())).thenAnswer { invocation ->
+ PrefetchResult(
+ url = invocation.arguments[0] as String,
+ htmlCached = false,
+ totalImages = 0,
+ cachedImages = 0,
+ isComplete = false
+ )
+ }
+ whenever(contentRepository.warmImage(any(), any())).thenReturn(true)
+
+ whenever(contentRepository.incrementChapterUrl(any())).thenAnswer { invocation ->
+ val url = invocation.arguments[0] as String
+ if (url.endsWith("c1")) "https://example.com/c2" else null
+ }
+ whenever(contentRepository.decrementChapterUrl(any())).thenAnswer { invocation ->
+ val url = invocation.arguments[0] as String
+ if (url.endsWith("c2")) "https://example.com/c1" else null
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun initViewModel(
+ lastReadUrl: String? = null,
+ lastReadLibraryItemId: String? = null
+ ) {
+ whenever(preferencesManager.lastReadUrl).thenReturn(lastReadUrl)
+ whenever(preferencesManager.lastReadLibraryItemId).thenReturn(lastReadLibraryItemId)
+
+ viewModel = ReaderViewModel(
+ contentRepository,
+ libraryRepository,
+ exploreRepository,
+ preferencesManager,
+ chapterListCache,
+ fakeImageDimensionCacheRepository()
+ )
+ }
+
+ private suspend fun mockChapterContent(
+ url: String,
+ paragraphsCount: Int = NUM_TEN,
+ prevUrl: String? = null,
+ nextUrl: String? = null
+ ): ChapterContent {
+ val elements = List(paragraphsCount) { ContentElement.Text("Paragraph $it") }
+ val content = ChapterContent(
+ paragraphs = elements,
+ title = "Chapter Title",
+ url = url,
+ previousChapterUrl = prevUrl,
+ nextChapterUrl = nextUrl
+ )
+ whenever(contentRepository.loadContent(url)).thenReturn(ContentResult.Success(elements, "Chapter Title", url))
+ return content
+ }
+
+ private fun createLibraryItem(
+ id: String,
+ title: String = "Novel Title",
+ url: String = "https://example.com/novel",
+ progress: Int = NUM_ZERO,
+ isCurrentlyReading: Boolean = false,
+ currentChapter: String = "Chapter 1",
+ currentChapterUrl: String = "https://example.com/chap1",
+ lastReadIndex: Int = NUM_ZERO,
+ lastScrollPosition: Float = FLOAT_ZERO,
+ lastReadElementKey: String = "",
+ lastReadOffsetFraction: Float = FRACTION_UNKNOWN,
+ lastRead: Long = NUM_ZERO.toLong(),
+ hasUpdates: Boolean = false,
+ isFinished: Boolean = false
+ ): LibraryItem {
+ return LibraryItem(
+ id = id,
+ title = title,
+ url = url,
+ progress = progress,
+ isCurrentlyReading = isCurrentlyReading,
+ currentChapter = currentChapter,
+ currentChapterUrl = currentChapterUrl,
+ lastReadIndex = lastReadIndex,
+ lastScrollPosition = lastScrollPosition,
+ lastReadElementKey = lastReadElementKey,
+ lastReadOffsetFraction = lastReadOffsetFraction,
+ lastRead = lastRead,
+ hasUpdates = hasUpdates,
+ contentType = ContentType.WEB,
+ baseTitle = title,
+ readingMode = ReadingMode.VERTICAL,
+ isDownloaded = isFinished,
+ dateAdded = lastRead
+ )
+ }
+
+ // =========================================================================
+ // TIER 1: Feature Coverage (>=5 cases per feature)
+ // =========================================================================
+
+ // --- Feature 1: Reader Scroll Motion / Navigation Gestures ---
+
+ @Test
+ fun testScrollMotion_updateScrollPosition_updatesUiState() = runTest {
+ initViewModel()
+ mockChapterContent("https://example.com/c1")
+ viewModel.loadContent("https://example.com/c1")
+ advanceUntilIdle()
+ viewModel.markUserDragged()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_THRESHOLD_VAL,
+ maxScrollOffset = FLOAT_THRESHOLD_VAL * NUM_TWO,
+ viewportHeight = FLOAT_THRESHOLD_VAL,
+ index = NUM_FIVE,
+ offsetFraction = FLOAT_ZERO_FIVE,
+ elementKey = "key",
+ firstVisibleItemSize = NUM_ONE_HUNDRED
+ )
+ assertEquals(NUM_FIVE, viewModel.progressState.value.scrollIndex)
+ assertEquals("key", viewModel.progressState.value.scrollElementKey)
+ }
+
+ @Test
+ fun testScrollMotion_smallDelta_doesNotTriggerScrollStart() {
+ assertFalse(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_3, FLOAT_DELTA_VAL_3), false))
+ }
+
+ @Test
+ fun testScrollMotion_largeDelta_triggersScrollStart() {
+ assertTrue(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_12, FLOAT_DELTA_VAL_12), false))
+ assertFalse(shouldDispatchReaderScrollStart(Offset(FLOAT_DELTA_VAL_12, FLOAT_DELTA_VAL_12), true))
+ }
+
+ @Test
+ fun testScrollMotion_markUserDragged_setsUserHasDragged() = runTest {
+ initViewModel()
+ assertFalse(viewModel.userHasDragged)
+ viewModel.markUserDragged()
+ assertTrue(viewModel.userHasDragged)
+ }
+
+ @Test
+ fun testScrollMotion_pagedMode_updatesScrollPositionWithPagedSize() = runTest {
+ initViewModel()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_ONE,
+ maxScrollOffset = FLOAT_ZERO,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_ONE,
+ offsetFraction = FLOAT_ZERO,
+ elementKey = "paged_key",
+ firstVisibleItemSize = PAGED_POSITION_ITEM_SIZE_PX
+ )
+ assertEquals(NUM_ONE, viewModel.progressState.value.scrollIndex)
+ assertEquals("paged_key", viewModel.progressState.value.scrollElementKey)
+ }
+
+ // --- Feature 2: Reader Controls Toggling ---
+
+ @Test
+ fun testControlsToggling_toggleControls_flipsState() {
+ initViewModel()
+ assertFalse(viewModel.uiState.value.showControls)
+ viewModel.toggleControls()
+ assertTrue(viewModel.uiState.value.showControls)
+ viewModel.toggleControls()
+ assertFalse(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testControlsToggling_hideControls_resetsState() {
+ initViewModel()
+ viewModel.toggleControls()
+ assertTrue(viewModel.uiState.value.showControls)
+ viewModel.hideControls()
+ assertFalse(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testControlsToggling_clearError_resetsErrorState() {
+ initViewModel()
+ viewModel.clearError()
+ assertNull(viewModel.uiState.value.error)
+ }
+
+ @Test
+ fun testControlsToggling_dismissFileConfirmation_closesDialog() {
+ initViewModel()
+ viewModel.requestOpenFile("content://test")
+ assertTrue(viewModel.uiState.value.showFileConfirmationDialog)
+ viewModel.dismissFileConfirmation()
+ assertFalse(viewModel.uiState.value.showFileConfirmationDialog)
+ }
+
+ @Test
+ fun testControlsToggling_showControls_whenErrorHappens() = runTest {
+ initViewModel()
+ whenever(contentRepository.loadContent("https://example.com/fail")).thenReturn(
+ ContentResult.Error("Network Timeout", null)
+ )
+ viewModel.loadContent("https://example.com/fail")
+ advanceUntilIdle()
+ assertEquals("Network Timeout", viewModel.uiState.value.error)
+ viewModel.clearError()
+ assertNull(viewModel.uiState.value.error)
+ }
+
+ // --- Feature 3: Library Drawer ---
+
+ @Test
+ fun testLibraryDrawer_buildDrawerNovelSections_emptyItems() {
+ val sections = buildDrawerNovelSections(emptyList())
+ assertNull(sections.continueNovel)
+ assertTrue(sections.recentUpdates.isEmpty())
+ assertTrue(sections.recentNovels.isEmpty())
+ }
+
+ @Test
+ fun testLibraryDrawer_buildDrawerNovelSections_singleItem() {
+ val item = createLibraryItem("1", "Title 1", "https://example.com/novel1")
+ val sections = buildDrawerNovelSections(listOf(item))
+ assertNotNull(sections.continueNovel)
+ assertEquals("Title 1", sections.continueNovel?.displayTitle)
+ }
+
+ @Test
+ fun testLibraryDrawer_buildDrawerNovelSections_recentUpdatesOnly() {
+ val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true)
+ val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", hasUpdates = true, progress = NUM_NINE * NUM_TEN + NUM_FIVE)
+ val sections = buildDrawerNovelSections(listOf(item1, item2))
+ assertEquals("Title 1", sections.continueNovel?.displayTitle)
+ assertEquals(NUM_ONE, sections.recentUpdates.size)
+ assertEquals("Title 2", sections.recentUpdates[NUM_ZERO].displayTitle)
+ }
+
+ @Test
+ fun testLibraryDrawer_buildDrawerNovelSections_recentNovelsOnly() {
+ val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true)
+ val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", hasUpdates = false)
+ val sections = buildDrawerNovelSections(listOf(item1, item2))
+ assertEquals("Title 1", sections.continueNovel?.displayTitle)
+ assertEquals(NUM_ONE, sections.recentNovels.size)
+ assertEquals("Title 2", sections.recentNovels[NUM_ZERO].displayTitle)
+ }
+
+ @Test
+ fun testLibraryDrawer_buildDrawerNovelSections_respectsContinueNovelPriority() {
+ val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", isCurrentlyReading = true, lastRead = NUM_ONE.toLong())
+ val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", isCurrentlyReading = false, lastRead = NUM_TEN.toLong())
+ val sections = buildDrawerNovelSections(listOf(item1, item2))
+ assertEquals("Title 1", sections.continueNovel?.displayTitle)
+ }
+
+ // --- Feature 4: State Restoration ---
+
+ @Test
+ fun testStateRestoration_initialState_loadsCorrectDefaults() {
+ initViewModel()
+ val state = viewModel.uiState.value
+ assertEquals(FLOAT_FONT_SIZE_DEFAULT, state.fontSize, FLOAT_ZERO)
+ assertEquals(ReaderTheme.DARK, state.readerTheme)
+ }
+
+ @Test
+ fun testStateRestoration_loadContent_restoresProgressWhenNotExplicit() = runTest {
+ val url = "https://example.com/chap1"
+ val item = createLibraryItem(
+ "item-1",
+ url = url,
+ progress = NUM_FIFTY,
+ lastScrollPosition = FLOAT_THRESHOLD_VAL,
+ lastReadIndex = NUM_FIVE,
+ lastReadOffsetFraction = FLOAT_ZERO_SIX
+ )
+ whenever(libraryRepository.getItemById("item-1")).thenReturn(item)
+ initViewModel()
+ mockChapterContent(url)
+ viewModel.loadContent(url, "item-1")
+ advanceUntilIdle()
+ assertEquals(NUM_FIVE, viewModel.uiState.value.scrollIndex)
+ assertEquals(FLOAT_ZERO_SIX, viewModel.uiState.value.restoreOffsetFraction, FLOAT_ZERO)
+ }
+
+ @Test
+ fun testStateRestoration_openChapterFromStart_ignoresSavedProgress() = runTest {
+ val url = "https://example.com/chap1"
+ val item = createLibraryItem(
+ "item-1",
+ url = url,
+ progress = NUM_FIFTY,
+ lastScrollPosition = FLOAT_THRESHOLD_VAL,
+ lastReadIndex = NUM_FIVE,
+ lastReadOffsetFraction = FLOAT_ZERO_SIX
+ )
+ whenever(libraryRepository.getItemById("item-1")).thenReturn(item)
+ initViewModel()
+ mockChapterContent(url)
+ viewModel.openChapterFromStart(url, "item-1")
+ advanceUntilIdle()
+ assertEquals(NUM_ZERO, viewModel.uiState.value.scrollIndex)
+ assertEquals(FLOAT_ZERO, viewModel.uiState.value.restoreOffsetFraction, FLOAT_ZERO)
+ }
+
+ @Test
+ fun testStateRestoration_loadContent_pdfPageRestore() = runTest {
+ val url = "/tmp/test.pdf"
+ val item = LibraryItem(
+ id = "pdf-1",
+ title = "PDF",
+ url = url,
+ contentType = ContentType.PDF,
+ progress = NUM_FIFTY,
+ lastReadIndex = NUM_FOUR,
+ lastScrollPosition = FLOAT_THRESHOLD_VAL
+ )
+ val pages = List(NUM_FIVE) { ContentElement.Placeholder("Loading") }
+ whenever(contentRepository.loadContent(url, NUM_FOUR)).thenReturn(ContentResult.Success(pages, "PDF", url))
+ whenever(libraryRepository.getItemById("pdf-1")).thenReturn(item)
+ initViewModel()
+ viewModel.loadContent(url, "pdf-1")
+ advanceUntilIdle()
+ verify(contentRepository).loadContent(url, NUM_FOUR)
+ assertEquals(NUM_FOUR, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testStateRestoration_beginRestore_setsProgressControllerState() {
+ initViewModel()
+ assertFalse(viewModel.restoreInProgress)
+ viewModel.beginRestore()
+ assertTrue(viewModel.restoreInProgress)
+ viewModel.markRestoreDone()
+ assertFalse(viewModel.restoreInProgress)
+ }
+
+ // =========================================================================
+ // TIER 2: Boundary & Corner Cases (>=5 cases per feature)
+ // =========================================================================
+
+ // --- Feature 1 (Scroll Motion Boundary Cases) ---
+
+ @Test
+ fun testScrollMotionBoundary_negativeDeltas_doesNotTriggerScrollStart() {
+ assertFalse(shouldDispatchReaderScrollStart(Offset(-FLOAT_DELTA_VAL_3, -FLOAT_DELTA_VAL_3), false))
+ }
+
+ @Test
+ fun testScrollMotionBoundary_scrollBeyondMaxOffset_capsPosition() = runTest {
+ initViewModel()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_THRESHOLD_VAL * NUM_TEN,
+ maxScrollOffset = FLOAT_THRESHOLD_VAL,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_ONE,
+ offsetFraction = FLOAT_ZERO,
+ elementKey = "key"
+ )
+ assertEquals(NUM_ONE, viewModel.progressState.value.scrollIndex)
+ }
+
+ @Test
+ fun testScrollMotionBoundary_offsetFractionBoundary_capsToZeroAndOne() = runTest {
+ initViewModel()
+ mockChapterContent("https://example.com/c1")
+ viewModel.loadContent("https://example.com/c1")
+ advanceUntilIdle()
+ viewModel.markUserDragged()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_ZERO,
+ maxScrollOffset = FLOAT_THRESHOLD_VAL,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_ZERO,
+ offsetFraction = FLOAT_ZERO_SIX,
+ elementKey = "key",
+ firstVisibleItemSize = NUM_ONE_HUNDRED
+ )
+ assertEquals(FLOAT_ZERO_SIX, viewModel.progressState.value.scrollOffsetFraction, FLOAT_ZERO)
+ }
+
+ @Test
+ fun testScrollMotionBoundary_emptyContent_scrollPositionNoOp() = runTest {
+ initViewModel()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_ZERO,
+ maxScrollOffset = FLOAT_ZERO,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_ZERO,
+ offsetFraction = FLOAT_ZERO,
+ elementKey = ""
+ )
+ assertEquals(NUM_ZERO, viewModel.progressState.value.scrollIndex)
+ }
+
+ @Test
+ fun testScrollMotionBoundary_pagedModePageOutOfBounds_coercesPage() = runTest {
+ initViewModel()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_ONE * NUM_TEN,
+ maxScrollOffset = FLOAT_ONE,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_TEN,
+ offsetFraction = FLOAT_ZERO,
+ elementKey = "key"
+ )
+ assertEquals(NUM_TEN, viewModel.progressState.value.scrollIndex)
+ }
+
+ // --- Feature 2 (Controls Toggling Boundary Cases) ---
+
+ @Test
+ fun testControlsTogglingBoundary_multipleToggleControls_togglesStateReliably() {
+ initViewModel()
+ viewModel.toggleControls()
+ viewModel.toggleControls()
+ viewModel.toggleControls()
+ assertTrue(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testControlsTogglingBoundary_hideControlsWhenAlreadyHidden_isNoOp() {
+ initViewModel()
+ assertFalse(viewModel.uiState.value.showControls)
+ viewModel.hideControls()
+ assertFalse(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testControlsTogglingBoundary_requestOpenFileUnsafeUri_showsToastNotDialog() {
+ val scheme = "ftp"
+ val isSupported = scheme == "file" || scheme == "content" || scheme == "http" || scheme == "https"
+ assertFalse(isSupported)
+ }
+
+ @Test
+ fun testControlsTogglingBoundary_requestOpenUrlUnsafe_showsToastNotDialog() = runTest {
+ initViewModel()
+ viewModel.requestOpenUrl("javascript:alert(1)")
+
+ // Poll for background IO dispatcher work execution
+ var toast: String? = null
+ for (i in 0 until NUM_FIFTY) {
+ toast = viewModel.uiState.value.toastMessage
+ if (toast != null) break
+ testDispatcher.scheduler.advanceTimeBy(NUM_TEN.toLong())
+ testDispatcher.scheduler.runCurrent()
+ Thread.sleep(NUM_TEN.toLong())
+ }
+ assertEquals("Blocked unsafe or invalid URL", toast)
+ assertFalse(viewModel.uiState.value.showExternalUrlConfirmation)
+ }
+
+ @Test
+ fun testControlsTogglingBoundary_toastMessageClear_resetsToast() {
+ initViewModel()
+ viewModel.clearError()
+ assertNull(viewModel.uiState.value.toastMessage)
+ }
+
+ // --- Feature 3 (Library Drawer Boundary Cases) ---
+
+ @Test
+ fun testLibraryDrawerBoundary_maxUpdatesCount_capsAtFour() {
+ val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true)
+ val items = List(NUM_TEN) { index ->
+ createLibraryItem(
+ (index + NUM_ONE).toString(),
+ "Title $index",
+ "https://example.com/n$index",
+ hasUpdates = true,
+ progress = NUM_NINE * NUM_TEN + NUM_FIVE,
+ lastRead = (index + NUM_ONE).toLong()
+ )
+ }
+ val sections = buildDrawerNovelSections(listOf(continueItem) + items)
+ assertTrue(sections.recentUpdates.size <= NUM_FOUR)
+ }
+
+ @Test
+ fun testLibraryDrawerBoundary_maxRecentNovelsCount_capsAtSix() {
+ val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true)
+ val items = List(NUM_TEN) { index ->
+ createLibraryItem(
+ (index + NUM_ONE).toString(),
+ "Title $index",
+ "https://example.com/n$index",
+ hasUpdates = false,
+ lastRead = (index + NUM_ONE).toLong()
+ )
+ }
+ val sections = buildDrawerNovelSections(listOf(continueItem) + items)
+ assertTrue(sections.recentNovels.size <= NUM_SIX)
+ }
+
+ @Test
+ fun testLibraryDrawerBoundary_duplicateNovelTitlesCount_countsDistinct() {
+ val item1 = createLibraryItem("1", "Title 1", "https://example.com/novel1")
+ val item2 = createLibraryItem("2", "Title 1", "https://example.com/novel1")
+ val item3 = createLibraryItem("3", "Title 2", "https://example.com/novel2")
+ val count = countDistinctNovelTitles(listOf(item1, item2, item3))
+ assertEquals(NUM_TWO, count)
+ }
+
+ @Test
+ fun testLibraryDrawerBoundary_finishedNovels_excludedFromRecent() {
+ val continueItem = createLibraryItem("0", "Continue", "https://example.com/c", isCurrentlyReading = true)
+ val finishedItem = createLibraryItem(
+ "1",
+ "Finished",
+ "https://example.com/f",
+ progress = NUM_ONE_HUNDRED,
+ isFinished = true
+ )
+ val sections = buildDrawerNovelSections(listOf(continueItem, finishedItem))
+ assertTrue(sections.recentNovels.none { it.displayTitle == "Finished" })
+ }
+
+ @Test
+ fun testLibraryDrawerBoundary_continueNovelResortsToMaxTimestamp() {
+ val item1 = createLibraryItem("1", "Title 1", "https://example.com/n1", lastRead = NUM_ONE.toLong())
+ val item2 = createLibraryItem("2", "Title 2", "https://example.com/n2", lastRead = NUM_TEN.toLong())
+ val sections = buildDrawerNovelSections(listOf(item1, item2))
+ assertEquals("Title 2", sections.continueNovel?.displayTitle)
+ }
+
+ // --- Feature 4 (State Restoration Boundary Cases) ---
+
+ @Test
+ fun testStateRestorationBoundary_toleranceSmokeCheck_runsPercentRestore() {
+ assertTrue(shouldRunPercentRestoreFallback(false, FLOAT_ZERO_FIVE))
+ // Precise restore with no usable intra-item fraction still runs the percent smoke check —
+ // the anchor may have landed at the wrong index after image reflow.
+ assertTrue(shouldRunPercentRestoreFallback(true, null))
+ assertTrue(shouldRunPercentRestoreFallback(true, FLOAT_ZERO))
+ // Precise restore WITH a real intra-item fraction is trusted; no fallback.
+ assertFalse(shouldRunPercentRestoreFallback(true, FLOAT_ZERO_FIVE))
+ }
+
+ @Test
+ fun testStateRestorationBoundary_toleranceSmokeCheck_preciseRestoreWithFraction_returnsFalse() {
+ assertFalse(shouldRunPercentRestoreFallback(true, FLOAT_ZERO_SIX))
+ }
+
+ @Test
+ fun testStateRestorationBoundary_resolveRestoreIndex_usesStableKeyIfFound() = runTest {
+ val url = "https://example.com/chap1"
+ val content = mockChapterContent(url)
+ val targetIndex = NUM_SEVEN
+ val key = stableContentElementKey(url, targetIndex, content.paragraphs[targetIndex])
+
+ val item = createLibraryItem(
+ "item-1",
+ url = url,
+ lastReadIndex = NUM_TWO,
+ lastReadElementKey = key
+ )
+ whenever(libraryRepository.getItemById("item-1")).thenReturn(item)
+ initViewModel()
+
+ viewModel.loadContent(url, "item-1")
+ advanceUntilIdle()
+
+ assertEquals(targetIndex, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testStateRestorationBoundary_resolveRestoreIndex_fallbackToScrollIndexIfKeyNotFound() = runTest {
+ val url = "https://example.com/chap1"
+ val item = createLibraryItem(
+ "item-1",
+ url = url,
+ lastReadIndex = NUM_FOUR,
+ lastReadElementKey = "invalid-key-non-existent"
+ )
+ whenever(libraryRepository.getItemById("item-1")).thenReturn(item)
+ initViewModel()
+
+ mockChapterContent(url)
+
+ viewModel.loadContent(url, "item-1")
+ advanceUntilIdle()
+
+ assertEquals(NUM_FOUR, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testStateRestorationBoundary_loadContentEmptyUrl_noOp() = runTest {
+ initViewModel()
+ viewModel.loadContent("")
+ advanceUntilIdle()
+ assertNull(viewModel.uiState.value.content)
+ }
+
+ // =========================================================================
+ // TIER 3: Cross-Feature Combinations
+ // =========================================================================
+
+ @Test
+ fun testCombo_scrollDuringRestore_marksUserDraggedAndCancelsRestoreLoop() = runTest {
+ initViewModel()
+ viewModel.beginRestore()
+ assertTrue(viewModel.restoreInProgress)
+ viewModel.markUserDragged()
+ assertTrue(viewModel.userHasDragged)
+ viewModel.markRestoreDone()
+ assertFalse(viewModel.restoreInProgress)
+ }
+
+ @Test
+ fun testCombo_scrollMotion_hidesControlsOnUserInput() = runTest {
+ initViewModel()
+ viewModel.toggleControls()
+ assertTrue(viewModel.uiState.value.showControls)
+ viewModel.hideControls()
+ assertFalse(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testCombo_drawerSelection_loadsContentAndResetsScrollState() = runTest {
+ initViewModel()
+ val url = "https://example.com/drawer-novel"
+ mockChapterContent(url)
+
+ viewModel.loadContent(url)
+ advanceUntilIdle()
+
+ assertEquals(url, viewModel.uiState.value.content?.url)
+ assertEquals(NUM_ZERO, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testCombo_loadContentWithRestore_keepsControlsHidden() = runTest {
+ val url = "https://example.com/chap"
+ val item = createLibraryItem("1", url = url, lastReadIndex = NUM_TWO)
+ whenever(libraryRepository.getItemById("1")).thenReturn(item)
+ initViewModel()
+ mockChapterContent(url)
+
+ viewModel.loadContent(url, "1")
+ advanceUntilIdle()
+
+ assertFalse(viewModel.uiState.value.showControls)
+ }
+
+ @Test
+ fun testCombo_drawerNavigationToSameChapter_restoresPreviousPosition() = runTest {
+ val url = "https://example.com/same"
+ val item = createLibraryItem("1", url = url, lastReadIndex = NUM_FIVE)
+ whenever(libraryRepository.getItemById("1")).thenReturn(item)
+ initViewModel()
+ mockChapterContent(url)
+
+ viewModel.loadContent(url, "1")
+ advanceUntilIdle()
+ assertEquals(NUM_FIVE, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testCombo_controlsShown_doesNotInterruptActiveRestoreState() {
+ initViewModel()
+ viewModel.beginRestore()
+ assertTrue(viewModel.restoreInProgress)
+ viewModel.toggleControls()
+ assertTrue(viewModel.uiState.value.showControls)
+ assertTrue(viewModel.restoreInProgress)
+ }
+
+ // =========================================================================
+ // TIER 4: Real-World Application Scenarios (>=5 scenarios)
+ // =========================================================================
+
+ @Test
+ fun testScenario_coldLaunch_loadsLastReadNovelAndRestoresState() = runTest {
+ val lastUrl = "https://example.com/last"
+ val lastItemId = "item-last"
+ val item = createLibraryItem(lastItemId, url = lastUrl, lastReadIndex = NUM_THREE)
+ whenever(libraryRepository.getItemById(lastItemId)).thenReturn(item)
+ mockChapterContent(lastUrl)
+
+ initViewModel(lastReadUrl = lastUrl, lastReadLibraryItemId = lastItemId)
+ advanceUntilIdle()
+
+ assertEquals(lastUrl, viewModel.uiState.value.content?.url)
+ assertEquals(NUM_THREE, viewModel.uiState.value.scrollIndex)
+ }
+
+ @Test
+ fun testScenario_readingFlow_userUpdatesSettings_updatesUiAndTheme() = runTest {
+ initViewModel()
+ mockChapterContent("https://example.com/c1")
+ viewModel.loadContent("https://example.com/c1")
+ advanceUntilIdle()
+
+ viewModel.updateFontSize(FLOAT_FONT_SIZE_DEFAULT + FLOAT_ONE)
+ viewModel.updateReaderTheme(ReaderTheme.OLED)
+
+ val state = viewModel.uiState.value
+ assertEquals(FLOAT_FONT_SIZE_DEFAULT + FLOAT_ONE, state.fontSize, FLOAT_ZERO)
+ assertEquals(ReaderTheme.OLED, state.readerTheme)
+ }
+
+ @Test
+ fun testScenario_chapterNavigationFlow_loadsNextAndPrevious() = runTest {
+ val url1 = "https://example.com/c1"
+ val url2 = "https://example.com/c2"
+
+ val item = createLibraryItem("1", url = url1)
+ whenever(libraryRepository.getItemById("1")).thenReturn(item)
+
+ initViewModel()
+
+ mockChapterContent(url1, nextUrl = url2)
+ mockChapterContent(url2, prevUrl = url1)
+
+ viewModel.loadContent(url1, "1")
+ advanceUntilIdle()
+
+ viewModel.navigateToNextChapter()
+ advanceUntilIdle()
+ assertEquals(url2, viewModel.uiState.value.content?.url)
+
+ viewModel.navigateToPreviousChapter(fromBottom = true)
+ advanceUntilIdle()
+ assertEquals(url1, viewModel.uiState.value.content?.url)
+ }
+
+ @Test
+ fun testScenario_errorFlow_triggersCloudflareDialog_retryLoadsContent() = runTest {
+ val url = "https://example.com/c1"
+ initViewModel()
+
+ whenever(contentRepository.loadContent(url)).thenReturn(ContentResult.Error("403 Forbidden", null))
+ viewModel.loadContent(url)
+ advanceUntilIdle()
+
+ assertEquals("403 Forbidden", viewModel.uiState.value.error)
+
+ mockChapterContent(url)
+ viewModel.retryLoad()
+ advanceUntilIdle()
+
+ assertNull(viewModel.uiState.value.error)
+ assertEquals(url, viewModel.uiState.value.content?.url)
+ }
+
+ @Test
+ fun testScenario_completionFlow_userReadsToEnd_persistsProgress() = runTest {
+ val url = "https://example.com/c1"
+ val item = createLibraryItem("1", url = url)
+ whenever(libraryRepository.getItemById("1")).thenReturn(item)
+
+ initViewModel()
+ mockChapterContent(url)
+ viewModel.loadContent(url, "1")
+ advanceUntilIdle()
+
+ viewModel.markUserDragged()
+ viewModel.updateScrollPosition(
+ scrollOffset = FLOAT_THRESHOLD_VAL,
+ maxScrollOffset = FLOAT_THRESHOLD_VAL,
+ viewportHeight = FLOAT_ZERO,
+ index = NUM_NINE,
+ offsetFraction = FLOAT_ZERO,
+ elementKey = "key"
+ )
+
+ viewModel.updateReadingProgress(
+ progress = NUM_ONE_HUNDRED,
+ scrollPosition = FLOAT_THRESHOLD_VAL,
+ index = NUM_NINE,
+ elementKey = "key",
+ offsetFraction = FLOAT_ZERO,
+ currentChapterUrl = url,
+ forcePersist = true
+ )
+ advanceUntilIdle()
+
+ verify(libraryRepository).updateProgressExplicit(
+ itemId = eq("1"),
+ currentChapter = any(),
+ progress = eq(FieldUpdate.Set(NUM_ONE_HUNDRED)),
+ currentChapterUrl = eq(FieldUpdate.Set(url)),
+ lastScrollProgress = any(),
+ lastReadIndex = any(),
+ lastReadElementKey = any(),
+ lastReadOffsetFraction = any()
+ )
+ }
+}
diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt
new file mode 100644
index 00000000..4b61bc29
--- /dev/null
+++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManagerTest.kt
@@ -0,0 +1,173 @@
+package io.aatricks.easyreader.ui.viewmodel
+
+import io.aatricks.easyreader.data.local.ImageDimensionDao
+import io.aatricks.easyreader.data.model.ImageDimensionEntity
+import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository
+import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ImageDimensionManagerTest {
+
+ @Test
+ fun `persist stores dimensions and applies content rebuild once`() = runTest {
+ val batches = mutableListOf