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>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(800 to 1200, manager.dimensionState("http://img/1.jpg").value) + assertEquals(listOf(mapOf("http://img/1.jpg" to (800 to 1200))), batches) + } + + @Test + fun `dimensionState returns the same observable instance and tracks persists`() = runTest { + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) {} + + val state = manager.dimensionState("http://img/1.jpg") + assertSame(state, manager.dimensionState("http://img/1.jpg")) + assertNull(state.value) + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(800 to 1200, state.value) + } + + @Test + fun `duplicate persist of identical dimensions is a no-op`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + // Re-entry of a recycled item re-reports the same dimensions. + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + + assertEquals(1, batches.size) + } + + @Test + fun `changed dimensions for the same url go through`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + manager.persistImageDimensions("http://img/1.jpg", 900, 1600) + advanceUntilIdle() + + assertEquals(900 to 1600, manager.dimensionState("http://img/1.jpg").value) + assertEquals(2, batches.size) + assertEquals(mapOf("http://img/1.jpg" to (900 to 1600)), batches[1]) + } + + @Test + fun `pruneForChapter drops stale urls and keeps current ones`() = runTest { + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) {} + + manager.persistImageDimensions("http://old/1.jpg", 800, 1200) + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + advanceUntilIdle() + + manager.pruneForChapter(setOf("http://kept/2.jpg", "http://new/3.jpg")) + + assertNull(manager.dimensionState("http://old/1.jpg").value) + assertEquals(900 to 1400, manager.dimensionState("http://kept/2.jpg").value) + } + + @Test + fun `pruneForChapter reschedules content apply only for surviving urls`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://old/1.jpg", 800, 1200) + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + // Prune before the 350ms apply debounce fires: the old chapter's update must not + // survive into the rebuilt batch, the kept one must. + manager.pruneForChapter(setOf("http://kept/2.jpg")) + advanceUntilIdle() + + assertEquals(listOf(mapOf("http://kept/2.jpg" to (900 to 1400))), batches) + } + + @Test + fun `pruneForChapter re-enqueues surviving dims for the content rebuild`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("http://kept/2.jpg", 900, 1400) + advanceUntilIdle() + assertEquals(1, batches.size) + + // A url shared with the previous chapter keeps its state, but the new chapter's + // element may still lack dimensions — prune must schedule a fresh apply because the + // duplicate-persist guard blocks any future decode from doing so. + manager.pruneForChapter(setOf("http://kept/2.jpg")) + advanceUntilIdle() + + assertEquals(2, batches.size) + assertEquals(mapOf("http://kept/2.jpg" to (900 to 1400)), batches[1]) + } + + @Test + fun `failed db flush re-queues the batch and retries on the next flush`() = runTest { + val dao = FlakyImageDimensionDao() + val manager = ImageDimensionManager(this, ImageDimensionCacheRepository(dao)) {} + + manager.persistImageDimensions("http://img/1.jpg", 800, 1200) + advanceUntilIdle() + assertTrue(dao.upserted.isEmpty()) + + // Without the re-queue, the duplicate-persist guard would swallow every later + // re-report of these dims and they would never reach the DB this session. + dao.failWrites = false + manager.persistImageDimensions("http://img/2.jpg", 700, 1000) + advanceUntilIdle() + + assertEquals(setOf("http://img/1.jpg", "http://img/2.jpg"), dao.upserted.keys) + } + + @Test + fun `blank url and non-positive dimensions are rejected`() = runTest { + val batches = mutableListOf>>() + val manager = ImageDimensionManager(this, fakeImageDimensionCacheRepository()) { batches += it } + + manager.persistImageDimensions("", 800, 1200) + manager.persistImageDimensions("http://img/1.jpg", 0, 1200) + manager.persistImageDimensions("http://img/1.jpg", 800, -1) + advanceUntilIdle() + + assertNull(manager.dimensionState("http://img/1.jpg").value) + assertEquals(0, batches.size) + } + + private class FlakyImageDimensionDao : ImageDimensionDao { + var failWrites = true + val upserted = mutableMapOf() + + override suspend fun getMany(urls: List, parserVersion: Int): List = + emptyList() + + override suspend fun upsert(entity: ImageDimensionEntity) { + upserted[entity.imageUrl] = entity + } + + override suspend fun upsertAll(entities: List) { + if (failWrites) throw RuntimeException("disk full") + entities.forEach { upserted[it.imageUrl] = it } + } + + override suspend fun prune(cutoffMs: Long, currentParserVersion: Int) = Unit + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index e0910713..e87e3184 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -37,8 +37,11 @@ class LibraryViewModelTest { @Before fun setup() { - testDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) + testDispatcher = StandardTestDispatcher() Dispatchers.setMain(testDispatcher) + LibraryViewModel.isUnderTest = true + LibraryViewModel.defaultDispatcher = testDispatcher + whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(emptyList())) whenever(libraryRepository.loadCollapsedSources()).thenReturn(emptySet()) whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(emptyMap()) @@ -62,6 +65,8 @@ class LibraryViewModelTest { @After fun tearDown() { Dispatchers.resetMain() + LibraryViewModel.isUnderTest = false + LibraryViewModel.defaultDispatcher = Dispatchers.Default } @Test diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt index 3c47481b..883cf773 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt @@ -764,6 +764,36 @@ class ReaderViewModelTest { assertEquals(1920, image.height) } + @Test + fun `navigating to a new chapter prunes previous chapter dimension state`() = runTest { + val itemId = "item-1" + val urlA = "https://example.com/webtoon/chapter-10" + val urlB = "https://example.com/webtoon/chapter-11" + val imageA = "https://cdn.example.com/panel-a.jpg" + val imageB = "https://cdn.example.com/panel-b.jpg" + + whenever(contentRepository.loadContent(urlA)).thenReturn( + ContentResult.Success(elements = listOf(ContentElement.Image(imageA)), title = "Chapter 10", url = urlA) + ) + whenever(contentRepository.loadContent(urlB)).thenReturn( + ContentResult.Success(elements = listOf(ContentElement.Image(imageB)), title = "Chapter 11", url = urlB) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Chapter 10", url = urlA) + ) + + viewModel.loadContent(urlA, itemId) + advanceUntilIdle() + viewModel.persistImageDimensions(imageA, 1080, 1920) + advanceUntilIdle() + assertEquals(1080 to 1920, viewModel.imageDimensionState(imageA).value) + + viewModel.loadContent(urlB, itemId) + advanceUntilIdle() + + assertNull(viewModel.imageDimensionState(imageA).value) + } + @Test fun `scroll progress saves when current item is stable even before upstream image resolves`() = runTest { val itemId = "item-1" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60c..d997cfc6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 82c2070d..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,3 @@ -#Thu Oct 23 12:43:41 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip diff --git a/gradlew b/gradlew index ef07e016..739907df 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell