Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
<ID>FunctionNaming:FileConfirmationDialog.kt$@Composable fun FileConfirmationDialog( fileUri: String, onConfirm: () -&gt; Unit, onCancel: () -&gt; Unit )</ID>
<ID>FunctionNaming:GridStateTiles.kt$@Composable fun ErrorTile( message: String, onRetry: (() -&gt; Unit)? = null, modifier: Modifier = Modifier )</ID>
<ID>FunctionNaming:GridStateTiles.kt$@Composable fun LoadingTile( modifier: Modifier = Modifier, height: androidx.compose.ui.unit.Dp = 180.dp )</ID>
<ID>FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -&gt; Unit, onCloseDrawer: () -&gt; Unit ): Unit</ID>
<ID>FunctionNaming:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -&gt; Unit, onCloseDrawer: () -&gt; Unit, onLibraryClick: () -&gt; Unit, onDiscoverClick: () -&gt; Unit, onOpenLibraryItem: (LibraryItem) -&gt; Unit, onOpenLatestUpdate: (LibraryItem) -&gt; Unit ): Unit</ID>
<ID>FunctionNaming:LibraryDrawerContent.kt$@Composable private fun ContinueReadingCard( item: LibraryItem, onClick: () -&gt; Unit ): Unit</ID>
<ID>FunctionNaming:LibraryDrawerContent.kt$@Composable private fun DrawerSectionLabel(text: String): Unit</ID>
<ID>FunctionNaming:LibraryDrawerContent.kt$@Composable private fun EmptyQuickAccessState(): Unit</ID>
Expand Down Expand Up @@ -149,7 +149,7 @@
<ID>LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreScreen( exploreViewModel: ExploreViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -&gt; Unit, onReadItem: (ExploreItem) -&gt; Unit ): Unit</ID>
<ID>LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ActiveFilterBar( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -&gt; Unit, onSourceSelect: (String?) -&gt; Unit, onTagToggle: (String) -&gt; Unit, onClearFilters: () -&gt; Unit ): Unit</ID>
<ID>LongMethod:ExploreScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun FiltersBottomSheetContent( uiState: ExploreViewModel.ExploreUiState, onSourceSelect: (String?) -&gt; Unit, onTagToggle: (String) -&gt; Unit, onClearTags: () -&gt; Unit, onClose: () -&gt; Unit ): Unit</ID>
<ID>LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( libraryViewModel: LibraryViewModel, readerViewModel: ReaderViewModel, navController: NavController, onOpenFilePicker: () -&gt; Unit, onCloseDrawer: () -&gt; Unit ): Unit</ID>
<ID>LongMethod:LibraryDrawerContent.kt$@Composable fun LibraryDrawerContent( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -&gt; Unit, onCloseDrawer: () -&gt; Unit, onLibraryClick: () -&gt; Unit, onDiscoverClick: () -&gt; Unit, onOpenLibraryItem: (LibraryItem) -&gt; Unit, onOpenLatestUpdate: (LibraryItem) -&gt; Unit ): Unit</ID>
<ID>LongMethod:LibraryItemCard.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryItemCard( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -&gt; Unit, onLongClick: () -&gt; Unit, onNewTagClick: (() -&gt; Unit)? = null, onDelete: (() -&gt; Unit)? = null, onResetProgress: (() -&gt; Unit)? = null, onMarkFinished: (() -&gt; Unit)? = null )</ID>
<ID>LongMethod:LibraryRepository.kt$LibraryRepository$suspend fun refreshLibraryUpdates( exploreRepository: ExploreRepository, ignoreActivityThreshold: Boolean = false ): Unit</ID>
<ID>LongMethod:LibraryScreen.kt$@Composable private fun AddNovelSection( urlInput: String, onUrlChange: (String) -&gt; Unit, onAddClick: () -&gt; Unit, onOpenPdfClick: () -&gt; Unit ): Unit</ID>
Expand Down Expand Up @@ -184,6 +184,7 @@
<ID>LongParameterList:ExploreGridSection.kt$( gridState: LazyGridState, modifier: Modifier = Modifier, uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onItemSelect: (ExploreItem) -&gt; Unit, onLoadMore: () -&gt; Unit, onClearFilters: () -&gt; Unit, onRetryFailedSource: (String) -&gt; Unit )</ID>
<ID>LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, hasActiveFilters: Boolean, onOpenFilters: () -&gt; Unit, onSourceSelect: (String?) -&gt; Unit, onTagToggle: (String) -&gt; Unit, onClearFilters: () -&gt; Unit )</ID>
<ID>LongParameterList:ExploreScreen.kt$( uiState: ExploreViewModel.ExploreUiState, modifier: Modifier = Modifier, hasActiveFilters: Boolean, onSearchQueryChange: (String) -&gt; Unit, onPerformSearch: () -&gt; Unit, onOpenFilters: () -&gt; Unit, onClearFilters: () -&gt; Unit, onSetBrowseMode: (BrowseMode) -&gt; Unit, onSourceSelect: (String?) -&gt; Unit, onTagToggle: (String) -&gt; Unit, onItemSelect: (ExploreItem) -&gt; Unit, onLoadMore: () -&gt; Unit, onRetryFailedSource: (String) -&gt; Unit )</ID>
<ID>LongParameterList:LibraryDrawerContent.kt$( drawerSections: DrawerNovelSections, isLibraryEmpty: Boolean, onOpenFilePicker: () -&gt; Unit, onCloseDrawer: () -&gt; Unit, onLibraryClick: () -&gt; Unit, onDiscoverClick: () -&gt; Unit, onOpenLibraryItem: (LibraryItem) -&gt; Unit, onOpenLatestUpdate: (LibraryItem) -&gt; Unit )</ID>
<ID>LongParameterList:LibraryItemCard.kt$( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -&gt; Unit, onLongClick: () -&gt; Unit, onNewTagClick: (() -&gt; Unit)? = null, onDelete: (() -&gt; Unit)? = null, onResetProgress: (() -&gt; Unit)? = null, onMarkFinished: (() -&gt; Unit)? = null )</ID>
<ID>LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String = "", progress: FieldUpdate&lt;Int&gt; = FieldUpdate.Unchanged, currentChapterUrl: FieldUpdate&lt;String&gt; = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate&lt;Float&gt; = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate&lt;Int&gt; = FieldUpdate.Unchanged, lastReadElementKey: FieldUpdate&lt;String&gt; = FieldUpdate.Unchanged, lastReadOffsetFraction: FieldUpdate&lt;Float&gt; = FieldUpdate.Unchanged )</ID>
<ID>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 )</ID>
Expand Down Expand Up @@ -301,12 +302,8 @@
<ID>MagicNumber:BaseJsoupSource.kt$BaseJsoupSource$15000L</ID>
<ID>MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$0xff</ID>
<ID>MagicNumber:CacheKeyUtils.kt$CacheKeyUtils$4</ID>
<ID>MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$3</ID>
<ID>MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$30</ID>
<ID>MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$4</ID>
<ID>MagicNumber:ChapterDownloadQueue.kt$WorkManagerChapterDownloadQueue$5</ID>
<ID>MagicNumber:ChapterListSheet.kt$100</ID>
<ID>MagicNumber:EasyReaderApplication.kt$EasyReaderApplication$0.25</ID>
<ID>MagicNumber:EpubContentLoader.kt$EpubContentLoader$1024</ID>
<ID>MagicNumber:EpubContentLoader.kt$EpubContentLoader$50</ID>
<ID>MagicNumber:EpubContentLoader.kt$EpubContentLoader.&lt;no name provided&gt;$5</ID>
Expand Down Expand Up @@ -579,6 +576,8 @@
<ID>MaxLineLength:ReaderProgressController.kt$ReaderProgressController$"saveProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(resolvedChapterUrl)} index=$lastIndex elementKey=${if (lastElementKey.isNotEmpty()) "&lt;set&gt;" else "&lt;empty&gt;"} fraction=$lastFraction firstVisibleItemSize=${latest.firstVisibleItemSize}"</ID>
<ID>MaxLineLength:ReaderProgressController.kt$ReaderProgressController$maxScrollOffset &gt; viewportHeight -&gt; ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f)</ID>
<ID>MaxLineLength:ReaderProgressController.kt$ReaderProgressController$val nextUiPendingRestoreOffsetFraction = if (uiPendingRestoreOffsetFraction != null) null else uiPendingRestoreOffsetFraction</ID>
<ID>MaxLineLength:ReaderScreen.kt$libraryViewModel</ID>
<ID>MaxLineLength:ReaderScreen.kt$val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url</ID>
<ID>MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip snap-to-top url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} dbProgress=${existing.progress}"</ID>
<ID>MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress skip unstable url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} firstVisibleItemSize=${latest.firstVisibleItemSize} fraction=${latest.scrollOffsetFraction}"</ID>
<ID>MaxLineLength:ReaderViewModel.kt$ReaderViewModel$"persistLifecycleProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} index=${latest.scrollIndex} fraction=${latest.scrollOffsetFraction} firstVisibleItemSize=${latest.firstVisibleItemSize}"</ID>
Expand Down Expand Up @@ -630,8 +629,9 @@
<ID>ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseJpeg(bytes: ByteArray): Pair&lt;Int, Int&gt;?</ID>
<ID>ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parsePng(bytes: ByteArray): Pair&lt;Int, Int&gt;?</ID>
<ID>ReturnCount:ImageBoundsParser.kt$ImageBoundsParser$private fun parseWebP(bytes: ByteArray): Pair&lt;Int, Int&gt;?</ID>
<ID>ReturnCount:ImageCache.kt$ImageCache$fun promoteToDownloads(url: String): File?</ID>
<ID>ReturnCount:ImageCache.kt$ImageCache$private fun File.isCachedImageValid(): Boolean</ID>
<ID>ReturnCount:ImageCache.kt$ImageCache$private fun promoteToDownloadsInternal(url: String): File?</ID>
<ID>ReturnCount:ImageDimensionCacheRepository.kt$ImageDimensionCacheRepository$suspend fun persistAll(entries: List&lt;Triple&lt;String, Int, Int&gt;&gt;): Boolean</ID>
<ID>ReturnCount:ImageDownloader.kt$ImageDownloader$private fun parseImageResponse( response: Response, destinationFile: File? = null ): ImageFetchResult</ID>
<ID>ReturnCount:ImageDownloader.kt$ImageDownloader$suspend fun executeImageRequest( imageUrl: String, pageUrl: String, priority: ImageRequestPriority, rangeHeader: String? = null, destinationFile: File? = null ): ImageFetchResult</ID>
<ID>ReturnCount:ImageIntegrity.kt$ImageIntegrity$fun isValidImageFile(file: File): Boolean</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,15 @@ data class ChapterContent(
)

/**
* Get all image URLs
* Get all image URLs, including images nested inside [ContentElement.PageContent]
*/
fun getAllImageUrls(): List<String> = paragraphs.flatMap {
fun getAllImageUrls(): List<String> = collectImageUrls(paragraphs)

private fun collectImageUrls(elements: List<ContentElement>): List<String> = 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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ class ImageDimensionCacheRepository @Inject constructor(
}
}

suspend fun persistAll(entries: List<Triple<String, Int, Int>>) {
if (entries.isEmpty()) return
/** @return true when the rows reached the DB (or there was nothing valid to write). */
suspend fun persistAll(entries: List<Triple<String, Int, Int>>): Boolean {
if (entries.isEmpty()) return true
val now = System.currentTimeMillis()
val rows = entries
.filter { it.first.isNotBlank() && it.second > 0 && it.third > 0 }
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -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<IntegrityKey, Boolean>()

private val mediaStateMemo = ConcurrentHashMap<String, String>()

private fun File.isCachedImageValid(): Boolean {
if (!exists() || length() <= 0L) return false
val key = IntegrityKey(absolutePath, length(), lastModified())
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading