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