From af8669d4cbefa23ee0a90872bb4df50f27e5d2a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 22:19:35 +0000 Subject: [PATCH 01/19] Stop marking chapters Downloaded when images permanently failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk download and retry-on-fail were declaring chapters "Downloaded" even when some images had failed permanently (404/403). The DB isDownloaded flag stuck on true, so opening such chapters surfaced "Image unavailable" for the missing pages, and on next app launch the chapter could flicker to "In library" before the inspect refresh ran. Root cause: inspectCacheInternal counted .failed-sidecar URLs toward effectiveCached and treated isComplete=true as "fully downloaded". syncDownloadedFlag then promoted the DB flag for chapters that were not actually on disk. Fix: - PrefetchResult gains hasPermanentFailures; cachedImages now reflects the actual on-disk count only. - isComplete keeps its "no further work to attempt" meaning so the download loop and auto-resume don't spin forever, but the DB flag and the "Downloaded" UI label both now require !hasPermanentFailures. - syncDownloadedFlag also demotes a previously-downloaded chapter once a finished USER_REQUESTED inspect confirms permanent failures, so chapters wrongly promoted by earlier sessions self-heal on next start. - chapterCacheStatusKind falls back to Downloaded when the DB remembers the chapter and the cache state hasn't been populated yet, so the badge no longer flickers to "In library" right after launch. - Permanent failures now keep isRetryable=true so the retry control stays visible — tapping it clears the sidecar and re-attempts. --- .../easyreader/data/model/PrefetchResult.kt | 8 +- .../repository/content/WebContentLoader.kt | 36 ++++++--- .../ui/components/ChapterListSheet.kt | 10 ++- .../ui/viewmodel/LibraryViewModel.kt | 27 +++++-- .../ChapterListSheetSelectionTest.kt | 32 ++++++++ .../ui/viewmodel/LibraryViewModelTest.kt | 76 +++++++++++++++++++ 6 files changed, 168 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt b/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt index d948760..329204e 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/model/PrefetchResult.kt @@ -13,5 +13,11 @@ data class PrefetchResult( val isComplete: Boolean, val isInProgress: Boolean = false, val isRetryable: Boolean = true, - val isPersistentDownload: Boolean = false + val isPersistentDownload: Boolean = false, + // True when some images were accounted as permanently failed (in the .failed sidecar) + // rather than actually on disk. isComplete can still be true (no further work to attempt) + // but the chapter is not fully present — opening it will show "Image unavailable" for the + // missing images. The DB isDownloaded flag and the "Downloaded" UI label should only + // appear when isComplete && !hasPermanentFailures. + val hasPermanentFailures: Boolean = false ) 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 e2bc32a..a0c0672 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 @@ -857,9 +857,18 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( ) val finalResult = inspected.copy( isInProgress = false, - isRetryable = allImagesRetryable && !inspected.isComplete + // Retry is useful when there's missing content (incomplete) OR we accepted + // permanent failures into the sidecar — clearing the sidecar and re-attempting + // can recover transient errors that were misclassified as permanent (e.g. a 404 + // returned by an overloaded CDN). + isRetryable = (allImagesRetryable && !inspected.isComplete) || inspected.hasPermanentFailures + ) + Log.d( + TAG, + "prefetch final result url=$safeUrl complete=${finalResult.isComplete} " + + "cached=${finalResult.cachedImages}/${finalResult.totalImages} " + + "permanentFailures=${finalResult.hasPermanentFailures}" ) - Log.d(TAG, "prefetch final result url=$safeUrl complete=${finalResult.isComplete} cached=${finalResult.cachedImages}/${finalResult.totalImages}") return finalResult } @@ -1029,11 +1038,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( val imageUrls = memo?.imageUrls.orEmpty() // Permanent failures (4xx that the fetcher gave up on) are tracked in the .failed - // sidecar. The download flow already treats them as accounted-for; the inspect flow - // must do the same when judging downloads-tier completeness, otherwise any chapter - // with a 404 image can never reach isComplete=true and the isDownloaded flag never - // sticks. Restricted to persistentOnly so cache-tier inspect can't be falsely - // completed by a stale sidecar from a never-finished download. + // sidecar. They count toward isComplete so the download loop and the auto-resume + // path don't keep retrying URLs we've already concluded are dead — but they are + // NOT actually on disk, so cachedImages reflects only the on-disk count and + // hasPermanentFailures signals the gap. The DB isDownloaded flag and the + // "Downloaded" UI label require isComplete && !hasPermanentFailures so chapters + // that can't be read offline don't masquerade as fully downloaded. val knownPermanent = if (persistentOnly) loadPermanentFailures(url) else emptySet() val downloadedCount = imageUrls.count { imageUrl -> if (persistentOnly) { @@ -1042,7 +1052,9 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( imageCache.findExistingCachedMediaFile(imageUrl) != null } } - val accountedPermanent = if (persistentOnly) imageUrls.count { it in knownPermanent } else 0 + val accountedPermanent = if (persistentOnly) { + imageUrls.count { it in knownPermanent && !imageCache.isDownloaded(it) } + } else 0 val effectiveCached = downloadedCount + accountedPermanent val isInProgress = chapterPrefetchMutex.withLock { url in inFlightChapterPrefetches } @@ -1052,16 +1064,18 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( memo?.hasImageTags == true -> false else -> memo?.bodyNonEmpty == true } + val hasPermanentFailures = imageUrls.isNotEmpty() && accountedPermanent > 0 return PrefetchResult( url = url, htmlCached = htmlCached, totalImages = imageUrls.size, - cachedImages = effectiveCached, + cachedImages = downloadedCount, isComplete = finalComplete, isInProgress = isInProgress, - isRetryable = !finalComplete, - isPersistentDownload = persistentOnly && htmlCached + isRetryable = !finalComplete || hasPermanentFailures, + isPersistentDownload = persistentOnly && htmlCached, + hasPermanentFailures = hasPermanentFailures ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt index cd21b91..5ddd54e 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt @@ -477,11 +477,19 @@ internal fun chapterCacheStatusKind( val hasManagedDownload = isDownloaded || (isInLibrary && cacheState?.isPersistentDownload == true) if (cacheState?.isInProgress == true && (isInLibrary || hasManagedDownload)) return ChapterStatus.Caching if (hasManagedDownload && cacheState != null) { - if (cacheState.isComplete) return ChapterStatus.Downloaded + // "Downloaded" requires the chapter to be fully present on disk. Chapters where some + // images ended up as permanent failures (404/403/etc.) are reported as incomplete so + // the user sees the missing-image count and a retry control instead of a misleading + // "Downloaded" badge that would then surface "Image unavailable" on open. + if (cacheState.isComplete && !cacheState.hasPermanentFailures) return ChapterStatus.Downloaded if (cacheState.totalImages > 0) { return ChapterStatus.DownloadIncomplete(cacheState.cachedImages, cacheState.totalImages) } } + // No cache state yet but the DB remembers this chapter as downloaded — trust the DB to + // avoid flickering to "In library" between app launch and the first inspect refresh. + // Once the refresh runs, the branch above takes over and corrects the label if needed. + if (isDownloaded && cacheState == null) return ChapterStatus.Downloaded if (isInLibrary) return ChapterStatus.InLibrary return null } 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 3309a92..4c8b22d 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 @@ -430,15 +430,26 @@ class LibraryViewModel @Inject constructor( syncDownloadedFlag(item, result) } - // Promote-only: inspection can confirm a chapter is now downloaded, but must never - // demote a previously-downloaded chapter. Demotion belongs to explicit user actions - // (removeDownload, library deletion, reader auto-delete) so a transient inspect miss - // (e.g. an image that's a permanent 404 outside the sidecar, a file mtime change) - // can't silently wipe the badge. + // The DB isDownloaded flag means "every image of this chapter is actually on disk and + // openable offline". Permanent failures (4xx images in the .failed sidecar) count toward + // isComplete (so the download loop and auto-resume stop), but they are NOT on disk and + // must not promote the flag — otherwise the chapter shows "Downloaded" while opening it + // surfaces "Image unavailable" for the missing images. + // + // Demotion is only permitted when we have a confident, terminal signal that the chapter + // is not fully downloaded: a USER_REQUESTED inspect that finished (not in progress) and + // either accepted permanent failures or could no longer find images that were previously + // counted. Transient inspect misses (e.g. images not yet inspected during startup) leave + // the flag alone. private suspend fun syncDownloadedFlag(item: LibraryItem, result: PrefetchResult) { - val shouldBeDownloaded = result.isPersistentDownload && result.isComplete - if (shouldBeDownloaded && !item.isDownloaded) { - repository.markDownloaded(item.id, true) + if (!result.isPersistentDownload) return + val isFullyDownloaded = result.isComplete && !result.hasPermanentFailures + if (isFullyDownloaded) { + if (!item.isDownloaded) repository.markDownloaded(item.id, true) + return + } + if (item.isDownloaded && !result.isInProgress && result.hasPermanentFailures) { + repository.markDownloaded(item.id, false) } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/components/ChapterListSheetSelectionTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/components/ChapterListSheetSelectionTest.kt index 8b11803..4a5d32d 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/components/ChapterListSheetSelectionTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/components/ChapterListSheetSelectionTest.kt @@ -166,4 +166,36 @@ class ChapterListSheetSelectionTest { assertNull(status) } + + @Test + fun `chapter cache status surfaces permanent failures as download incomplete not downloaded`() { + val status = chapterCacheStatusText( + isCurrent = false, + cacheState = PrefetchResult( + url = "url-1", + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = true + ), + isInLibrary = true, + isDownloaded = true + ) + + assertEquals("Download incomplete: 3/5 images", status) + } + + @Test + fun `chapter cache status falls back to downloaded when db remembers download and cache state is missing`() { + val status = chapterCacheStatusText( + isCurrent = false, + cacheState = null, + isInLibrary = true, + isDownloaded = true + ) + + assertEquals("Downloaded", status) + } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index 32e7218..275a39d 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -451,4 +451,80 @@ class LibraryViewModelTest { verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) assertEquals(inspected, activeViewModel.uiState.value.chapterCacheStates[chapterUrl]) } + + @Test + fun `addChapters does not mark download when permanent failures are present`() = runTest { + val chapter = ChapterInfo("Chapter 14", "https://example.com/novel/chapter-14") + val partialResult = PrefetchResult( + url = chapter.url, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = true + ) + + whenever(libraryRepository.getItemByUrl(chapter.url)).thenReturn(null) + whenever( + libraryRepository.addItem(any(), any(), any(), any(), any(), any(), any(), any()) + ).thenReturn( + LibraryItem( + id = "chapter-14-id", + title = chapter.title, + url = chapter.url, + currentChapter = "Chapter 14", + baseTitle = "Novel", + baseNovelUrl = "https://example.com/novel", + sourceName = "Source1" + ) + ) + whenever(contentRepository.prefetchWithProgress(eq(chapter.url), eq(PrefetchMode.USER_REQUESTED), any())) + .thenReturn(partialResult) + + viewModel.addChapters( + chapters = listOf(chapter), + baseTitle = "Novel", + baseNovelUrl = "https://example.com/novel", + sourceName = "Source1" + ) + advanceUntilIdle() + + verify(libraryRepository, never()).markDownloaded(eq("chapter-14-id"), eq(true)) + assertEquals(partialResult, viewModel.uiState.value.chapterCacheStates[chapter.url]) + } + + @Test + fun `refreshChapterCacheStates demotes downloaded flag when permanent failures show up`() = runTest { + val chapterUrl = "https://example.com/novel/chapter-15" + val downloadedItem = LibraryItem( + id = "chapter-15-id", + title = "Chapter 15", + url = chapterUrl, + currentChapter = "Chapter 15", + baseTitle = "Novel", + isDownloaded = true + ) + val libraryItems = MutableStateFlow(listOf(downloadedItem)) + val inspected = PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = true + ) + + whenever(libraryRepository.libraryItems).thenReturn(libraryItems) + whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inspected) + + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) + advanceUntilIdle() + + verify(libraryRepository, timeout(1000)).markDownloaded(downloadedItem.id, false) + assertEquals(inspected, activeViewModel.uiState.value.chapterCacheStates[chapterUrl]) + } } From 03bc656c0049452296fa57a969118a072e4a8d18 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 20 May 2026 21:57:13 -0400 Subject: [PATCH 02/19] Add WorkManager chapter downloads --- app/build.gradle.kts | 3 + .../7.json | 279 ++++++++++++++++++ .../easyreader/EasyReaderApplication.kt | 14 +- .../easyreader/data/local/AppDatabase.kt | 26 +- .../data/local/ChapterImageStateDao.kt | 53 ++++ .../data/model/ChapterImageStateEntity.kt | 38 +++ .../data/repository/content/ImageCache.kt | 29 +- .../content/PermanentFailureStore.kt | 46 +++ .../repository/content/WebContentLoader.kt | 89 ++++-- .../di/ChapterDownloadQueueModule.kt | 17 ++ .../aatricks/easyreader/di/DatabaseModule.kt | 10 +- .../di/PermanentFailureStoreModule.kt | 17 ++ .../ui/viewmodel/LibraryViewModel.kt | 56 ++-- .../easyreader/util/ImageIntegrity.kt | 83 ++++++ .../easyreader/work/ChapterDownloadQueue.kt | 131 ++++++++ .../easyreader/work/ChapterDownloadWorker.kt | 120 ++++++++ .../data/local/AppDatabaseMigrationTest.kt | 79 ++++- .../repository/ContentRepositoryEpubTest.kt | 2 +- .../content/InMemoryPermanentFailureStore.kt | 32 ++ .../WebContentLoaderPrefetchRetryTest.kt | 153 +++++++++- .../content/WebContentLoaderTest.kt | 18 +- .../ui/viewmodel/LibraryViewModelTest.kt | 17 +- .../easyreader/util/ImageIntegrityTest.kt | 72 +++++ .../work/ChapterDownloadWorkerTest.kt | 198 +++++++++++++ .../work/NoOpChapterDownloadQueue.kt | 17 ++ .../easyreader/work/TestApplication.kt | 9 + gradle/libs.versions.toml | 4 + 27 files changed, 1549 insertions(+), 63 deletions(-) create mode 100644 app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json create mode 100644 app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/data/repository/content/PermanentFailureStore.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/di/ChapterDownloadQueueModule.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/di/PermanentFailureStoreModule.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadQueue.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/data/repository/content/InMemoryPermanentFailureStore.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/work/NoOpChapterDownloadQueue.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/work/TestApplication.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d0492c..0531199 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,6 +186,8 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) // Room implementation(libs.room.runtime) @@ -235,6 +237,7 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json new file mode 100644 index 0000000..7e10f4b --- /dev/null +++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/7.json @@ -0,0 +1,279 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "10a0cd3f0bd0509f9541b5bdc69de3f5", + "entities": [ + { + "tableName": "library_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadOffset` INTEGER NOT NULL, `lastReadOffsetFraction` REAL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCurrentlyReading", + "columnName": "isCurrentlyReading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentChapter", + "columnName": "currentChapter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentChapterUrl", + "columnName": "currentChapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalChapters", + "columnName": "totalChapters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "dateAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloading", + "columnName": "isDownloading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrollPosition", + "columnName": "lastScrollPosition", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lastReadIndex", + "columnName": "lastReadIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOffset", + "columnName": "lastReadOffset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOffsetFraction", + "columnName": "lastReadOffsetFraction", + "affinity": "REAL" + }, + { + "fieldPath": "hasUpdates", + "columnName": "hasUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterSummaries", + "columnName": "chapterSummaries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseTitle", + "columnName": "baseTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readingMode", + "columnName": "readingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseNovelUrl", + "columnName": "baseNovelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_library_items_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_library_items_baseTitle", + "unique": false, + "columnNames": [ + "baseTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)" + }, + { + "name": "index_library_items_isCurrentlyReading", + "unique": false, + "columnNames": [ + "isCurrentlyReading" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)" + }, + { + "name": "index_library_items_lastRead", + "unique": false, + "columnNames": [ + "lastRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)" + } + ] + }, + { + "tableName": "chapter_image_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))", + "fields": [ + { + "fieldPath": "chapterUrl", + "columnName": "chapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptMs", + "columnName": "lastAttemptMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "httpStatusCode", + "columnName": "httpStatusCode", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chapterUrl", + "imageUrl" + ] + }, + "indices": [ + { + "name": "index_chapter_image_state_chapterUrl", + "unique": false, + "columnNames": [ + "chapterUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)" + }, + { + "name": "index_chapter_image_state_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '10a0cd3f0bd0509f9541b5bdc69de3f5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt index 21e1f78..0766383 100644 --- a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt +++ b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt @@ -2,6 +2,8 @@ package io.aatricks.easyreader import android.app.Application import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader @@ -22,11 +24,21 @@ import okhttp3.OkHttpClient import javax.inject.Inject @HiltAndroidApp -class EasyReaderApplication : Application(), SingletonImageLoader.Factory { +class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Configuration.Provider { @Inject lateinit var okHttpClient: OkHttpClient @Inject lateinit var contentRepository: ContentRepository @Inject lateinit var preferencesManager: PreferencesManager + @Inject lateinit var workerFactory: HiltWorkerFactory + + // WorkManager pulls this lazily before its first enqueue, which happens after Hilt + // injection has populated `workerFactory`. Using on-demand initialization (no manual + // `WorkManager.initialize`) means the test variant can override via WorkManagerTestInitHelper. + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .setMinimumLoggingLevel(Log.INFO) + .build() private val warmupScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt index 1d86b50..6f5e9a3 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt @@ -5,12 +5,18 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import io.aatricks.easyreader.data.model.ChapterImageStateEntity import io.aatricks.easyreader.data.model.LibraryItem -@Database(entities = [LibraryItem::class], version = 6, exportSchema = true) +@Database( + entities = [LibraryItem::class, ChapterImageStateEntity::class], + version = 7, + exportSchema = true +) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao + abstract fun chapterImageStateDao(): ChapterImageStateDao companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -193,5 +199,23 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE library_items ADD COLUMN downloadedAt INTEGER") } } + + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS chapter_image_state ( + chapterUrl TEXT NOT NULL, + imageUrl TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + lastAttemptMs INTEGER NOT NULL DEFAULT 0, + httpStatusCode INTEGER, + PRIMARY KEY(chapterUrl, imageUrl) + ) + """.trimIndent()) + db.execSQL("CREATE INDEX IF NOT EXISTS index_chapter_image_state_chapterUrl ON chapter_image_state (chapterUrl)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_chapter_image_state_status ON chapter_image_state (status)") + } + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt new file mode 100644 index 0000000..f118180 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/local/ChapterImageStateDao.kt @@ -0,0 +1,53 @@ +package io.aatricks.easyreader.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.aatricks.easyreader.data.model.ChapterImageStateEntity + +@Dao +interface ChapterImageStateDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: ChapterImageStateEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(entities: List) + + @Query(""" + SELECT imageUrl FROM chapter_image_state + WHERE chapterUrl = :chapterUrl + AND status = :status + AND lastAttemptMs > :freshAfterMs + """) + suspend fun getActiveImagesByStatus( + chapterUrl: String, + status: String, + freshAfterMs: Long + ): List + + @Query(""" + SELECT * FROM chapter_image_state + WHERE chapterUrl = :chapterUrl + AND status = :status + """) + suspend fun getAllByStatus( + chapterUrl: String, + status: String + ): List + + @Query("DELETE FROM chapter_image_state WHERE chapterUrl = :chapterUrl") + suspend fun deleteAllForChapter(chapterUrl: String) + + @Query(""" + DELETE FROM chapter_image_state + WHERE chapterUrl = :chapterUrl AND status = :status + """) + suspend fun deleteForChapterByStatus(chapterUrl: String, status: String) + + @Query(""" + DELETE FROM chapter_image_state + WHERE status = :status AND lastAttemptMs < :olderThanMs + """) + suspend fun pruneExpired(status: String, olderThanMs: Long) +} diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt new file mode 100644 index 0000000..f4cb4b1 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/model/ChapterImageStateEntity.kt @@ -0,0 +1,38 @@ +package io.aatricks.easyreader.data.model + +import androidx.room.Entity +import androidx.room.Index + +/** + * Per-image bookkeeping for chapter downloads. Replaces the on-disk `.failed` sidecar so + * permanent-failure state survives cache eviction and is queryable as a set rather than a + * per-chapter file read. Status values: + * + * - `PERMANENT_FAILURE` — fetcher classified the URL as a 4xx-style dead URL (see HttpRetry). + * Counts toward `isComplete` (loop has nothing more to attempt) but the image is not on + * disk; surfaced as `hasPermanentFailures` so the UI/DB-flag won't claim "Downloaded". + * Entries have a TTL so a transient CDN 4xx auto-recovers after a day. + * + * The on-disk image file is still the source of truth for "downloaded"; this table only + * tracks what we've given up retrying. + */ +@Entity( + tableName = "chapter_image_state", + primaryKeys = ["chapterUrl", "imageUrl"], + indices = [ + Index(value = ["chapterUrl"]), + Index(value = ["status"]) + ] +) +data class ChapterImageStateEntity( + val chapterUrl: String, + val imageUrl: String, + val status: String, + val attempts: Int = 0, + val lastAttemptMs: Long = 0L, + val httpStatusCode: Int? = null +) { + companion object { + const val STATUS_PERMANENT_FAILURE = "PERMANENT_FAILURE" + } +} diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt index ef82dc3..8cd0400 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt @@ -4,7 +4,9 @@ import io.aatricks.easyreader.di.MediaCacheDir import io.aatricks.easyreader.di.MediaDownloadsDir import io.aatricks.easyreader.util.CacheKeyUtils import io.aatricks.easyreader.util.FileSizeUtils +import io.aatricks.easyreader.util.ImageIntegrity import java.io.File +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -37,8 +39,31 @@ class ImageCache @Inject constructor( } } - fun isDownloaded(url: String): Boolean = - File(mediaDownloadsDir, CacheKeyUtils.keyFor(url)).exists() + fun isDownloaded(url: String): Boolean { + val file = File(mediaDownloadsDir, CacheKeyUtils.keyFor(url)) + return file.isCachedImageValid() + } + + fun isValidImageFile(file: File): Boolean = file.isCachedImageValid() + + // Verdict cache keyed by (path, length, mtime) so re-checking a file we already validated + // doesn't pay the disk read repeatedly. Invalidates automatically on any file mutation. + private data class IntegrityKey(val path: String, val length: Long, val mtime: Long) + private val integrityVerdicts = ConcurrentHashMap() + + private fun File.isCachedImageValid(): Boolean { + if (!exists() || length() <= 0L) return false + val key = IntegrityKey(absolutePath, length(), lastModified()) + integrityVerdicts[key]?.let { return it } + val verdict = ImageIntegrity.isValidImageFile(this) + if (integrityVerdicts.size > MAX_INTEGRITY_VERDICTS) integrityVerdicts.clear() + integrityVerdicts[key] = verdict + return verdict + } + + private companion object { + private const val MAX_INTEGRITY_VERDICTS = 4096 + } fun deleteCachedMediaFiles(url: String) { candidateFiles(url).forEach { it.delete() } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/PermanentFailureStore.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/PermanentFailureStore.kt new file mode 100644 index 0000000..1c3caec --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/PermanentFailureStore.kt @@ -0,0 +1,46 @@ +package io.aatricks.easyreader.data.repository.content + +import io.aatricks.easyreader.data.local.ChapterImageStateDao +import io.aatricks.easyreader.data.model.ChapterImageStateEntity +import io.aatricks.easyreader.data.model.ChapterImageStateEntity.Companion.STATUS_PERMANENT_FAILURE +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tracks per-image permanent failure state for chapter downloads. Replaces the on-disk + * `.failed` sidecar so state survives cache eviction. TTL filtering happens at read time + * so a CDN that returned a transient 4xx can be retried after the freshness window. + */ +interface PermanentFailureStore { + suspend fun load(chapterUrl: String, freshAfterMs: Long): Set + suspend fun record(chapterUrl: String, imageUrls: Collection, recordedAtMs: Long) + suspend fun clear(chapterUrl: String) +} + +@Singleton +class RoomPermanentFailureStore @Inject constructor( + private val dao: ChapterImageStateDao +) : PermanentFailureStore { + + override suspend fun load(chapterUrl: String, freshAfterMs: Long): Set { + return dao.getActiveImagesByStatus(chapterUrl, STATUS_PERMANENT_FAILURE, freshAfterMs).toSet() + } + + override suspend fun record(chapterUrl: String, imageUrls: Collection, recordedAtMs: Long) { + if (imageUrls.isEmpty()) return + val entities = imageUrls.distinct().map { imageUrl -> + ChapterImageStateEntity( + chapterUrl = chapterUrl, + imageUrl = imageUrl, + status = STATUS_PERMANENT_FAILURE, + attempts = 1, + lastAttemptMs = recordedAtMs + ) + } + dao.upsertAll(entities) + } + + override suspend fun clear(chapterUrl: String) { + dao.deleteForChapterByStatus(chapterUrl, STATUS_PERMANENT_FAILURE) + } +} 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 a0c0672..f80468b 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 @@ -50,7 +50,8 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( private val imageDownloader: ImageDownloader, private val parsedContentCache: ParsedContentCache, @HtmlCacheDir private val cacheDir: File, - @HtmlDownloadsDir private val downloadsDir: File + @HtmlDownloadsDir private val downloadsDir: File, + private val permanentFailureStore: PermanentFailureStore ) { companion object { private const val TAG = "WebContentLoader" @@ -69,6 +70,10 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( private const val FETCH_REMOTE_DIMENSIONS_DURING_INITIAL_LOAD = false private const val USER_HTML_TIMEOUT_SECONDS = 15L private const val MAX_PARSED_IMAGE_MEMO = 128 + // Permanent failures (4xx images recorded in the `.failed` sidecar) get a TTL so we + // re-attempt them after a day. Some CDNs return 404 transiently when overloaded; an + // entry that's still 404 after the TTL gets re-recorded with a fresh timestamp. + private const val PERMANENT_FAILURE_TTL_MS = 24L * 60L * 60L * 1000L } // Process-lifetime scope for background image prefetches that intentionally @@ -333,11 +338,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( parsedImageMemo.remove(url) } - fun clearPermanentFailures(url: String) { + suspend fun clearPermanentFailures(url: String) { sidecarFileVariants(url).forEach { it.delete() } + permanentFailureStore.clear(url) } - fun clearDownload(url: String) { + suspend fun clearDownload(url: String) { val sourceForImageList = primaryCachedFile(url, StorageTier.DOWNLOADS) .takeIf(File::exists) ?: findExistingCachedFile(url) @@ -354,6 +360,7 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( primaryCachedFile(url, StorageTier.DOWNLOADS).delete() File(downloadsDir, "${CacheKeyUtils.keyFor(url)}.html.failed").delete() parsedImageMemo.remove(url) + permanentFailureStore.clear(url) } suspend fun resetInFlightState(url: String) { @@ -972,14 +979,23 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( when (result) { is ImageFetchResult.Success -> { - if (tempFile.renameTo(cachedFile)) { - ImageDownloadResult.Success(cachedFile) - } else if (cachedFile.exists()) { - tempFile.delete() - ImageDownloadResult.Success(cachedFile) - } else { + val finalFile = when { + tempFile.renameTo(cachedFile) -> cachedFile + cachedFile.exists() -> { tempFile.delete(); cachedFile } + else -> null + } + if (finalFile == null) { tempFile.delete() ImageDownloadResult.Failure(false) + } else if (!imageCache.isValidImageFile(finalFile)) { + // Server returned non-image bytes (HTML challenge, truncated + // payload). Treat as retryable so the next pass tries again + // and only escalates to a permanent failure on real HTTP 4xx. + Log.d(TAG, "Invalid image payload, deleting and retrying: ${UrlSanitizer.sanitize(imageUrl)}") + finalFile.delete() + ImageDownloadResult.Failure(true) + } else { + ImageDownloadResult.Success(finalFile) } } is ImageFetchResult.HttpError -> { @@ -1230,23 +1246,50 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( private fun sidecarFileVariants(url: String): List = cacheFileVariants(url).map { File(it.parent, "${it.name}.failed") } - private fun loadPermanentFailures(url: String): Set { - val files = sidecarFileVariants(url).filter(File::exists) - if (files.isEmpty()) return emptySet() - return files.flatMap { f -> - runCatching { f.readLines().filter { it.isNotBlank() } }.getOrDefault(emptyList()) - }.toSet() + // Permanent failures are persisted in the chapter_image_state Room table via + // PermanentFailureStore. TTL filtering lives in the store (load takes freshAfterMs). + // Legacy `.failed` sidecar files from before the Room migration are imported on first + // read and then deleted, so existing installs upgrade losslessly. + private suspend fun loadPermanentFailures( + url: String, + now: Long = System.currentTimeMillis() + ): Set { + migrateLegacySidecarIfPresent(url, now) + return permanentFailureStore.load(url, freshAfterMs = now - PERMANENT_FAILURE_TTL_MS) } - private fun recordPermanentFailures(url: String, failures: List) { + private suspend fun recordPermanentFailures(url: String, failures: List) { if (failures.isEmpty()) return - val htmlFile = findExistingCachedFile(url) ?: return - val sidecar = File(htmlFile.parent, "${htmlFile.name}.failed") - val existing = loadPermanentFailures(url) - val combined = (existing + failures).distinct() - runCatching { - sidecar.parentFile?.mkdirs() - sidecar.writeText(combined.joinToString("\n")) + permanentFailureStore.record(url, failures, recordedAtMs = System.currentTimeMillis()) + } + + // Reads any pre-migration sidecar entries, imports the still-fresh ones (those that + // already have a timestamp younger than the TTL) into the store, and removes the sidecar + // file. Legacy entries that pre-date the timestamp format are dropped — they'd be + // immediately expired by the TTL anyway, and dropping them gives the URL one fresh + // attempt which is the safer post-upgrade behavior. + private suspend fun migrateLegacySidecarIfPresent(url: String, now: Long) { + val sidecarFiles = sidecarFileVariants(url).filter(File::exists) + if (sidecarFiles.isEmpty()) return + val parsed = LinkedHashMap() + for (f in sidecarFiles) { + val lines = runCatching { f.readLines() }.getOrDefault(emptyList()) + for (line in lines) { + val trimmed = line.trim() + if (trimmed.isEmpty()) continue + val sep = trimmed.lastIndexOf('|') + if (sep <= 0) continue + val imageUrl = trimmed.substring(0, sep) + val ts = trimmed.substring(sep + 1).toLongOrNull() ?: continue + if (imageUrl.isEmpty()) continue + if (now - ts >= PERMANENT_FAILURE_TTL_MS) continue + val prev = parsed[imageUrl] + if (prev == null || ts > prev) parsed[imageUrl] = ts + } + } + if (parsed.isNotEmpty()) { + permanentFailureStore.record(url, parsed.keys, recordedAtMs = now) } + sidecarFiles.forEach { it.delete() } } } diff --git a/app/src/main/java/io/aatricks/easyreader/di/ChapterDownloadQueueModule.kt b/app/src/main/java/io/aatricks/easyreader/di/ChapterDownloadQueueModule.kt new file mode 100644 index 0000000..d4f1a21 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/di/ChapterDownloadQueueModule.kt @@ -0,0 +1,17 @@ +package io.aatricks.easyreader.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.aatricks.easyreader.work.ChapterDownloadQueue +import io.aatricks.easyreader.work.WorkManagerChapterDownloadQueue +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class ChapterDownloadQueueModule { + @Binds + @Singleton + abstract fun bindChapterDownloadQueue(impl: WorkManagerChapterDownloadQueue): ChapterDownloadQueue +} diff --git a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt index 9195e43..2e1fb32 100644 --- a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt +++ b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import io.aatricks.easyreader.data.local.AppDatabase +import io.aatricks.easyreader.data.local.ChapterImageStateDao import io.aatricks.easyreader.data.local.LibraryDao import javax.inject.Singleton @@ -27,7 +28,8 @@ object DatabaseModule { AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, - AppDatabase.MIGRATION_5_6 + AppDatabase.MIGRATION_5_6, + AppDatabase.MIGRATION_6_7 ) .build() } @@ -37,4 +39,10 @@ object DatabaseModule { fun provideLibraryDao(database: AppDatabase): LibraryDao { return database.libraryDao() } + + @Provides + @Singleton + fun provideChapterImageStateDao(database: AppDatabase): ChapterImageStateDao { + return database.chapterImageStateDao() + } } diff --git a/app/src/main/java/io/aatricks/easyreader/di/PermanentFailureStoreModule.kt b/app/src/main/java/io/aatricks/easyreader/di/PermanentFailureStoreModule.kt new file mode 100644 index 0000000..7eb5eb3 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/di/PermanentFailureStoreModule.kt @@ -0,0 +1,17 @@ +package io.aatricks.easyreader.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.aatricks.easyreader.data.repository.content.PermanentFailureStore +import io.aatricks.easyreader.data.repository.content.RoomPermanentFailureStore +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class PermanentFailureStoreModule { + @Binds + @Singleton + abstract fun bindPermanentFailureStore(impl: RoomPermanentFailureStore): PermanentFailureStore +} 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 4c8b22d..29048f2 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 @@ -17,6 +17,7 @@ import io.aatricks.easyreader.data.repository.ExploreRepository 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 @@ -34,7 +35,8 @@ import android.util.Log class LibraryViewModel @Inject constructor( val repository: LibraryRepository, private val contentRepository: ContentRepository, - private val exploreRepository: ExploreRepository + private val exploreRepository: ExploreRepository, + private val downloadQueue: ChapterDownloadQueue ) : BaseViewModel(LibraryUiState()) { private val TAG = "LibraryViewModel" @@ -77,29 +79,38 @@ class LibraryViewModel @Inject constructor( private fun verifyDownloadedItemsOnStartup() { viewModelScope.launch { - val urls = runCatching { - repository.getAllItemsSnapshot() - .map { it.url } - .filter { it.isNotBlank() } + 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) - autoResumeIncompleteDownloads(results) - } - } - - private fun autoResumeIncompleteDownloads(results: List) { - val targets = results.filter { - it.isPersistentDownload && - it.htmlCached && - !it.isComplete && - !it.isInProgress && - it.totalImages > 0 && - it.cachedImages < it.totalImages + 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 { contentRepository.beginUserDownload(it.url) } + targets.forEach { downloadQueue.enqueue(it.url) } val gate = Semaphore(LIBRARY_PREFETCH_CONCURRENCY) viewModelScope.launch { supervisorScope { @@ -384,6 +395,12 @@ class LibraryViewModel @Inject constructor( sourceName: String ): Unit { chapters.forEach { contentRepository.beginUserDownload(it.url) } + // Dual-path: the in-process prefetch below drives the UI in the foreground session. + // The WorkManager enqueue is resilience-only — if the app/process dies before the + // in-process prefetch finishes, the worker resumes the download in the background. + // The worker short-circuits via inspectDownload when the in-process side already + // completed, so this is not a 2x cost for typical sessions. + chapters.forEach { downloadQueue.enqueue(it.url) } viewModelScope.launch { chapters.forEach { chapter -> try { @@ -550,6 +567,7 @@ class LibraryViewModel @Inject constructor( repository.libraryItems.value } items.forEach { contentRepository.beginUserDownload(it.url) } + items.forEach { downloadQueue.enqueue(it.url) } val gate = Semaphore(LIBRARY_PREFETCH_CONCURRENCY) supervisorScope { items.map { item -> @@ -591,6 +609,9 @@ class LibraryViewModel @Inject constructor( fun retryDownload(url: String): Unit { contentRepository.beginUserDownload(url) + // Replace any in-flight worker so the retry runs on a fresh attempt with the + // permanent-failure cleanup reflected. + downloadQueue.enqueue(url, replaceExisting = true) viewModelScope.launch { try { runCatching { contentRepository.clearPermanentFailures(url) } @@ -616,6 +637,7 @@ class LibraryViewModel @Inject constructor( fun removeDownload(itemId: String): Unit { viewModelScope.launch { val item = repository.getItemById(itemId) ?: return@launch + downloadQueue.cancel(item.url) runCatching { contentRepository.clearDownload(item.url) repository.markDownloaded(itemId, false) diff --git a/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt new file mode 100644 index 0000000..aa95986 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt @@ -0,0 +1,83 @@ +package io.aatricks.easyreader.util + +import java.io.File + +/** + * Cheap integrity check for cached image files. Catches truncated downloads, zero-byte files, + * and HTML error pages (Cloudflare/CDN challenges) returned with an image content-type that + * `File.exists()` alone would treat as a successful download. + */ +object ImageIntegrity { + // Conservative lower bound — only rejects obviously-truncated downloads (zero bytes, + // a few bytes of partial header). The magic-byte check is the real validator; the size + // check just guards against `readHeader` returning a too-short array. PNG signature + // alone is 8 bytes, JPEG SOI is 2 bytes, WebP needs 12 bytes for the RIFF+WEBP brand. + private const val MIN_VALID_IMAGE_BYTES = 16L + private const val SNIFF_BYTES = 32 + + fun isValidImageFile(file: File): Boolean { + if (!file.exists() || file.length() < MIN_VALID_IMAGE_BYTES) return false + val header = readHeader(file) ?: return false + return classify(header) == ImageKind.Image + } + + private enum class ImageKind { Image, Html, Unknown } + + private fun classify(header: ByteArray): ImageKind { + if (looksLikeImage(header)) return ImageKind.Image + if (looksLikeHtml(header)) return ImageKind.Html + return ImageKind.Unknown + } + + private fun looksLikeImage(header: ByteArray): Boolean { + if (header.size < 4) return false + // JPEG: FF D8 FF + if (header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() && header[2] == 0xFF.toByte()) return true + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (header.size >= 8 && + header[0] == 0x89.toByte() && header[1] == 0x50.toByte() && + header[2] == 0x4E.toByte() && header[3] == 0x47.toByte() && + header[4] == 0x0D.toByte() && header[5] == 0x0A.toByte() && + header[6] == 0x1A.toByte() && header[7] == 0x0A.toByte()) return true + // GIF87a / GIF89a + if (header.size >= 6 && + header[0] == 'G'.code.toByte() && header[1] == 'I'.code.toByte() && + header[2] == 'F'.code.toByte() && header[3] == '8'.code.toByte() && + (header[4] == '7'.code.toByte() || header[4] == '9'.code.toByte()) && + header[5] == 'a'.code.toByte()) return true + // WebP: "RIFF" .... "WEBP" + if (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()) return true + // BMP: "BM" + if (header[0] == 'B'.code.toByte() && header[1] == 'M'.code.toByte()) return true + // AVIF / HEIF: ftyp box at offset 4 (skip 4-byte size), then "ftyp" + brand + if (header.size >= 12 && + header[4] == 'f'.code.toByte() && header[5] == 't'.code.toByte() && + header[6] == 'y'.code.toByte() && header[7] == 'p'.code.toByte()) return true + // SVG: starts with " + val bytes = ByteArray(SNIFF_BYTES) + val read = stream.read(bytes) + if (read <= 0) null else bytes.copyOf(read) + } + }.getOrNull() +} diff --git a/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadQueue.kt b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadQueue.kt new file mode 100644 index 0000000..f91b204 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadQueue.kt @@ -0,0 +1,131 @@ +package io.aatricks.easyreader.work + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import dagger.hilt.android.qualifiers.ApplicationContext +import io.aatricks.easyreader.data.model.PrefetchResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Wraps the WorkManager API for chapter downloads so the rest of the app deals in URLs and + * `PrefetchResult` rather than `WorkRequest` / `WorkInfo`. Enqueueing is keyed by chapter + * URL via unique work names, so a duplicate enqueue while a download is in flight either + * coalesces (KEEP) or replaces (REPLACE) deterministically. + */ +interface ChapterDownloadQueue { + fun enqueue(url: String, replaceExisting: Boolean = false) + fun cancel(url: String) + fun observeChapter(url: String): Flow + fun observeAll(): Flow> + + companion object { + const val TAG_CHAPTER_DOWNLOAD = "chapter-download" + } +} + +@Singleton +class WorkManagerChapterDownloadQueue @Inject constructor( + @ApplicationContext private val context: Context +) : ChapterDownloadQueue { + private val workManager: WorkManager get() = WorkManager.getInstance(context) + + override fun enqueue(url: String, replaceExisting: Boolean) { + if (url.isBlank()) return + val request = OneTimeWorkRequestBuilder() + .addTag(ChapterDownloadQueue.TAG_CHAPTER_DOWNLOAD) + .addTag(tagFor(url)) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to url)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + val policy = if (replaceExisting) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP + workManager.enqueueUniqueWork(uniqueName(url), policy, request) + } + + override fun cancel(url: String) { + if (url.isBlank()) return + workManager.cancelUniqueWork(uniqueName(url)) + } + + /** Live progress for a single chapter, regardless of which WorkInfo currently owns it. */ + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeChapter(url: String): Flow { + if (url.isBlank()) return flowOf(null) + return workManager.getWorkInfosForUniqueWorkFlow(uniqueName(url)) + .map { infos -> infos.lastOrNull() } + .distinctUntilChanged() + .flatMapLatest { info -> flowOf(info?.toPrefetchResult(url)) } + } + + /** All chapter downloads currently tracked by WorkManager. */ + override fun observeAll(): Flow> { + return workManager.getWorkInfosByTagFlow(ChapterDownloadQueue.TAG_CHAPTER_DOWNLOAD) + .map { infos -> + infos.asSequence() + .mapNotNull { info -> + val url = info.chapterUrl() ?: return@mapNotNull null + url to info.toPrefetchResult(url) + } + .toMap() + } + .distinctUntilChanged() + } + + private fun uniqueName(url: String): String = "$UNIQUE_PREFIX${urlKey(url)}" + private fun tagFor(url: String): String = "$URL_TAG_PREFIX${urlKey(url)}" + private fun urlKey(url: String): String = url.hashCode().toString() + + private fun WorkInfo.chapterUrl(): String? { + progress.getString(ChapterDownloadWorker.KEY_CHAPTER_URL) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return outputData.getString(ChapterDownloadWorker.KEY_CHAPTER_URL)?.takeIf { it.isNotBlank() } + } + + private fun WorkInfo.toPrefetchResult(fallbackUrl: String): PrefetchResult { + val payload: Data = if (progress.keyValueMap.isNotEmpty()) progress else outputData + val url = payload.getString(ChapterDownloadWorker.KEY_CHAPTER_URL) ?: fallbackUrl + val inProgress = state == WorkInfo.State.RUNNING || + state == WorkInfo.State.ENQUEUED || + state == WorkInfo.State.BLOCKED + // For terminal states without progress data (e.g. failed pre-publish), fall back to + // sensible defaults that won't promote the DB isDownloaded flag. + return PrefetchResult( + url = url, + htmlCached = payload.getBoolean(ChapterDownloadWorker.KEY_HTML_CACHED, false), + totalImages = payload.getInt(ChapterDownloadWorker.KEY_TOTAL_IMAGES, 0), + cachedImages = payload.getInt(ChapterDownloadWorker.KEY_CACHED_IMAGES, 0), + isComplete = payload.getBoolean(ChapterDownloadWorker.KEY_IS_COMPLETE, false) && + state == WorkInfo.State.SUCCEEDED, + isInProgress = inProgress && !payload.getBoolean(ChapterDownloadWorker.KEY_IS_COMPLETE, false), + isRetryable = payload.getBoolean(ChapterDownloadWorker.KEY_IS_RETRYABLE, state != WorkInfo.State.SUCCEEDED), + isPersistentDownload = payload.getBoolean(ChapterDownloadWorker.KEY_IS_PERSISTENT_DOWNLOAD, true), + hasPermanentFailures = payload.getBoolean(ChapterDownloadWorker.KEY_HAS_PERMANENT_FAILURES, false) + ) + } + + companion object { + private const val UNIQUE_PREFIX = "chapter-download:" + private const val URL_TAG_PREFIX = "chapter-url:" + } +} diff --git a/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt new file mode 100644 index 0000000..1775cd9 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt @@ -0,0 +1,120 @@ +package io.aatricks.easyreader.work + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.aatricks.easyreader.data.model.PrefetchMode +import io.aatricks.easyreader.data.model.PrefetchResult +import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.util.UrlSanitizer + +/** + * Persists chapter downloads to WorkManager so they survive process death and OS-level + * backgrounding. Bulk downloads and per-chapter retries enqueue one of these per chapter. + * + * Progress is published via `setProgress` so observers (LibraryViewModel) can mirror the + * latest [PrefetchResult] into the UI without keeping the prefetch work tied to the VM + * lifecycle. + */ +@HiltWorker +class ChapterDownloadWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val contentRepository: ContentRepository +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val url = inputData.getString(KEY_CHAPTER_URL) + if (url.isNullOrBlank()) { + Log.w(TAG, "missing chapter url, skipping") + return Result.failure() + } + + val safeUrl = UrlSanitizer.sanitize(url) + Log.d(TAG, "starting download url=$safeUrl runAttempt=$runAttemptCount") + + // Dual-path with the in-process VM call means the in-process side often finishes the + // download before the worker dispatches. Short-circuit here so we don't trigger a + // full re-inspect, re-record permanent failures, or churn the chapterPrefetchMutex + // for chapters that are already complete. + val existing = runCatching { contentRepository.inspectDownload(url) }.getOrNull() + if (existing != null && existing.isComplete && !existing.hasPermanentFailures) { + Log.d(TAG, "already complete, skipping worker url=$safeUrl") + publishProgress(existing) + return Result.success(existing.toTerminalData()) + } + + contentRepository.beginUserDownload(url) + return try { + val result = contentRepository.prefetchWithProgress( + url = url, + mode = PrefetchMode.USER_REQUESTED + ) { progress -> publishProgress(progress) } + + publishProgress(result) + val terminal = result.toTerminalData() + // Treat "complete with permanent failures" as success — the loop has nothing more + // to do. The badge logic separately downgrades it via hasPermanentFailures. + when { + result.isComplete -> Result.success(terminal) + result.isRetryable -> Result.retry() + else -> Result.failure(terminal) + } + } catch (cancel: kotlinx.coroutines.CancellationException) { + throw cancel + } catch (t: Throwable) { + Log.w(TAG, "download error url=$safeUrl message=${t.message}") + if (runAttemptCount < MAX_RUN_ATTEMPTS) Result.retry() else Result.failure() + } finally { + contentRepository.endUserDownload(url) + } + } + + private suspend fun publishProgress(progress: PrefetchResult) { + setProgress( + workDataOf( + KEY_CHAPTER_URL to progress.url, + KEY_HTML_CACHED to progress.htmlCached, + KEY_TOTAL_IMAGES to progress.totalImages, + KEY_CACHED_IMAGES to progress.cachedImages, + KEY_IS_COMPLETE to progress.isComplete, + KEY_IS_IN_PROGRESS to progress.isInProgress, + KEY_IS_RETRYABLE to progress.isRetryable, + KEY_IS_PERSISTENT_DOWNLOAD to progress.isPersistentDownload, + KEY_HAS_PERMANENT_FAILURES to progress.hasPermanentFailures + ) + ) + } + + private fun PrefetchResult.toTerminalData() = workDataOf( + KEY_CHAPTER_URL to url, + KEY_HTML_CACHED to htmlCached, + KEY_TOTAL_IMAGES to totalImages, + KEY_CACHED_IMAGES to cachedImages, + KEY_IS_COMPLETE to isComplete, + KEY_IS_IN_PROGRESS to false, + KEY_IS_RETRYABLE to isRetryable, + KEY_IS_PERSISTENT_DOWNLOAD to isPersistentDownload, + KEY_HAS_PERMANENT_FAILURES to hasPermanentFailures + ) + + companion object { + private const val TAG = "ChapterDownloadWorker" + private const val MAX_RUN_ATTEMPTS = 5 + + const val KEY_CHAPTER_URL = "chapter_url" + const val KEY_HTML_CACHED = "html_cached" + const val KEY_TOTAL_IMAGES = "total_images" + const val KEY_CACHED_IMAGES = "cached_images" + const val KEY_IS_COMPLETE = "is_complete" + const val KEY_IS_IN_PROGRESS = "is_in_progress" + const val KEY_IS_RETRYABLE = "is_retryable" + const val KEY_IS_PERSISTENT_DOWNLOAD = "is_persistent_download" + const val KEY_HAS_PERMANENT_FAILURES = "has_permanent_failures" + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt index 070b837..882e71a 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt @@ -135,6 +135,71 @@ class AppDatabaseMigrationTest { } } + @Test + fun migrate6To7_createsChapterImageStateTableWithIndices() { + val dbName = migrationDbName("6-to-7") + createDatabaseAtVersion( + dbName = dbName, + version = 6, + createTableSql = """ + CREATE TABLE library_items ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + timestamp INTEGER NOT NULL, + progress INTEGER NOT NULL, + isCurrentlyReading INTEGER NOT NULL, + currentChapter TEXT NOT NULL, + currentChapterUrl TEXT NOT NULL, + totalChapters INTEGER NOT NULL, + contentType TEXT NOT NULL, + dateAdded INTEGER NOT NULL, + lastRead INTEGER NOT NULL, + isDownloading INTEGER NOT NULL, + lastScrollPosition REAL NOT NULL, + lastReadIndex INTEGER NOT NULL, + lastReadOffset INTEGER NOT NULL, + lastReadOffsetFraction REAL, + hasUpdates INTEGER NOT NULL, + chapterSummaries TEXT NOT NULL, + baseTitle TEXT NOT NULL, + readingMode TEXT NOT NULL, + baseNovelUrl TEXT NOT NULL, + sourceName TEXT NOT NULL, + isDownloaded INTEGER NOT NULL DEFAULT 0, + downloadedAt INTEGER + ) + """.trimIndent(), + indexSqls = CURRENT_INDEX_SQL, + insertSqls = emptyList() + ) + + migrationTestHelper.runMigrationsAndValidate( + dbName, + 7, + true, + AppDatabase.MIGRATION_6_7 + ).use { database -> + assertTrue("chapter_image_state table must exist", hasTable(database, "chapter_image_state")) + assertIndexExists(database, "index_chapter_image_state_chapterUrl") + assertIndexExists(database, "index_chapter_image_state_status") + // Insert a row and confirm composite primary key behavior. + database.execSQL( + "INSERT INTO chapter_image_state (chapterUrl, imageUrl, status, attempts, lastAttemptMs, httpStatusCode) " + + "VALUES ('c1', 'i1', 'PERMANENT_FAILURE', 1, 12345, 404)" + ) + database.execSQL( + "INSERT OR REPLACE INTO chapter_image_state (chapterUrl, imageUrl, status, attempts, lastAttemptMs, httpStatusCode) " + + "VALUES ('c1', 'i1', 'PERMANENT_FAILURE', 2, 67890, 404)" + ) + database.query("SELECT attempts, lastAttemptMs FROM chapter_image_state WHERE chapterUrl='c1' AND imageUrl='i1'").use { c -> + assertTrue(c.moveToNext()) + assertEquals(2, c.getInt(0)) + assertEquals(67890L, c.getLong(1)) + } + } + } + @Test fun migrate4To5() { val dbName = migrationDbName("4-to-5") @@ -395,6 +460,15 @@ class AppDatabaseMigrationTest { return false } + private fun hasTable(database: SupportSQLiteDatabase, tableName: String): Boolean { + database.query( + SimpleSQLiteQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + arrayOf(tableName) + ) + ).use { cursor -> return cursor.moveToNext() } + } + private fun rowCount(database: SupportSQLiteDatabase): Int { database.query(SimpleSQLiteQuery("SELECT COUNT(*) FROM library_items")).use { cursor -> check(cursor.moveToFirst()) { "Expected count cursor row." } @@ -514,13 +588,14 @@ class AppDatabaseMigrationTest { } companion object { - private const val CURRENT_VERSION = 6 + private const val CURRENT_VERSION = 7 private val ALL_MIGRATIONS = arrayOf( AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, - AppDatabase.MIGRATION_5_6 + AppDatabase.MIGRATION_5_6, + AppDatabase.MIGRATION_6_7 ) private val CURRENT_INDEX_SQL = listOf( "CREATE UNIQUE INDEX index_library_items_url ON library_items (url)", diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt index accf6c0..09b31de 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt @@ -89,7 +89,7 @@ class ContentRepositoryEpubTest { // Use anyString() for Uri.parse mockedUriStatic.`when` { Uri.parse(org.mockito.ArgumentMatchers.anyString()) }.thenReturn(mockUri) - val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCache, mediaDownloads), ImageDownloader(okHttpClient), ParsedContentCache(), htmlCache, htmlDownloads) + val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCache, mediaDownloads), ImageDownloader(okHttpClient), ParsedContentCache(), htmlCache, htmlDownloads, io.aatricks.easyreader.data.repository.content.InMemoryPermanentFailureStore()) val pdfLoader = PdfContentLoader(mockContext, DefaultPdfDocumentOpener(mockContext)) val epubLoader = EpubContentLoader(mockContext, epubCache, epubDownloads) val localLoader = LocalContentLoader(mockContext, mockHtmlParser, pdfLoader, epubLoader, contentUriTypeResolver) diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/InMemoryPermanentFailureStore.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/InMemoryPermanentFailureStore.kt new file mode 100644 index 0000000..33a954c --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/InMemoryPermanentFailureStore.kt @@ -0,0 +1,32 @@ +package io.aatricks.easyreader.data.repository.content + +import java.util.concurrent.ConcurrentHashMap + +/** + * Test-only in-memory implementation of [PermanentFailureStore]. Mirrors the timestamp + + * TTL semantics of the Room-backed store without the Robolectric dependency. + */ +class InMemoryPermanentFailureStore : PermanentFailureStore { + private data class Entry(val recordedAtMs: Long) + private val data = ConcurrentHashMap>() + + override suspend fun load(chapterUrl: String, freshAfterMs: Long): Set { + val perChapter = data[chapterUrl] ?: return emptySet() + return perChapter.asSequence() + .filter { (_, entry) -> entry.recordedAtMs > freshAfterMs } + .map { it.key } + .toSet() + } + + override suspend fun record(chapterUrl: String, imageUrls: Collection, recordedAtMs: Long) { + if (imageUrls.isEmpty()) return + val bucket = data.getOrPut(chapterUrl) { ConcurrentHashMap() } + for (imageUrl in imageUrls) { + bucket[imageUrl] = Entry(recordedAtMs) + } + } + + override suspend fun clear(chapterUrl: String) { + data.remove(chapterUrl) + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt index 490028b..2843652 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.data.repository.content import io.aatricks.easyreader.data.model.ContentElement import io.aatricks.easyreader.data.model.PrefetchMode import io.aatricks.easyreader.data.repository.HtmlParser +import io.aatricks.easyreader.util.CacheKeyUtils import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import okhttp3.Interceptor @@ -54,13 +55,138 @@ class WebContentLoaderPrefetchRetryTest { } // Permanent failures (4xx) are accounted for via the .failed sidecar so the chapter - // counts as complete offline — there's nothing more we can fetch. The sidecar also - // suppresses retries so we only see one network request. + // counts as complete and we don't keep hammering the dead URL during this pass — + // hence only one network request. However the user-facing retry button stays + // available (isRetryable=true) because a 4xx can be a transient CDN response that + // clears on a manual retry; clearing the sidecar will trigger a fresh attempt. assertTrue(result.isComplete) - assertFalse(result.isRetryable) + assertTrue(result.isRetryable) assertEquals(1, imageRequests.get()) } + @Test + fun `permanent failure store entries expire after ttl and trigger fresh retry`() = runBlocking { + val chapterUrl = "https://example.com/expiring-permanent" + val imageUrl = "https://example.com/expiring.jpg" + val htmlParser = mock() + val imageRequests = AtomicInteger(0) + + val store = InMemoryPermanentFailureStore() + val harness = createLoaderWithDirs( + htmlParser = htmlParser, + interceptor = Interceptor { chain -> + val request = chain.request() + when (request.url.toString()) { + chapterUrl -> buildResponse(request, "", "text/html") + imageUrl -> { + imageRequests.incrementAndGet() + buildResponse(request, "Not Found", "text/plain", code = 404) + } + else -> buildResponse(request, "", "text/plain", code = 404) + } + }, + store = store + ) + + whenever(htmlParser.parse(any(), eq(chapterUrl))).thenReturn(listOf(ContentElement.Image(imageUrl))) + + // Pre-seed an expired permanent-failure entry so the loader's TTL filter ignores it + // and treats the URL as eligible for a fresh attempt. Models the "CDN was transient" + // recovery path users hit a day after a transient 4xx was misclassified. + val twoDaysAgo = System.currentTimeMillis() - 48L * 60L * 60L * 1000L + store.record(chapterUrl, listOf(imageUrl), recordedAtMs = twoDaysAgo) + + harness.loader.prefetch(chapterUrl, PrefetchMode.USER_REQUESTED) + assertTrue("expired store entry must trigger a fresh image request", imageRequests.get() >= 1) + } + + @Test + fun `legacy sidecar with valid timestamp is imported into store and deleted`() = runBlocking { + val chapterUrl = "https://example.com/legacy-with-ts" + val imageUrl = "https://example.com/legacy-ts.jpg" + val htmlParser = mock() + val imageRequests = AtomicInteger(0) + + val store = InMemoryPermanentFailureStore() + val harness = createLoaderWithDirs( + htmlParser = htmlParser, + interceptor = Interceptor { chain -> + val request = chain.request() + when (request.url.toString()) { + chapterUrl -> buildResponse(request, "", "text/html") + imageUrl -> { + imageRequests.incrementAndGet() + buildResponse(request, "Not Found", "text/plain", code = 404) + } + else -> buildResponse(request, "", "text/plain", code = 404) + } + }, + store = store + ) + + whenever(htmlParser.parse(any(), eq(chapterUrl))).thenReturn(listOf(ContentElement.Image(imageUrl))) + + // Pre-populate the chapter HTML cache so the sidecar location is meaningful, then + // write a legacy timestamped sidecar file to model the pre-Room-migration state. + harness.loader.prefetch(chapterUrl, PrefetchMode.USER_REQUESTED) + store.clear(chapterUrl) + val sidecar = File(harness.htmlDownloadsDir, "${CacheKeyUtils.keyFor(chapterUrl)}.html.failed") + val freshTimestamp = System.currentTimeMillis() + sidecar.writeText("$imageUrl|$freshTimestamp") + + val attemptsBefore = imageRequests.get() + harness.loader.prefetch(chapterUrl, PrefetchMode.USER_REQUESTED) + + // After migration the sidecar should be gone and the store should hold the entry. + assertFalse("sidecar must be removed after migration import", sidecar.exists()) + assertTrue( + "imported sidecar entry must suppress further requests during the same pass", + imageRequests.get() == attemptsBefore + ) + assertEquals(setOf(imageUrl), store.load(chapterUrl, freshAfterMs = freshTimestamp - 1)) + } + + @Test + fun `legacy sidecar without timestamp is dropped on migration`() = runBlocking { + val chapterUrl = "https://example.com/legacy-no-ts" + val imageUrl = "https://example.com/legacy-no-ts.jpg" + val htmlParser = mock() + val imageRequests = AtomicInteger(0) + + val store = InMemoryPermanentFailureStore() + val harness = createLoaderWithDirs( + htmlParser = htmlParser, + interceptor = Interceptor { chain -> + val request = chain.request() + when (request.url.toString()) { + chapterUrl -> buildResponse(request, "", "text/html") + imageUrl -> { + imageRequests.incrementAndGet() + buildResponse(request, "Not Found", "text/plain", code = 404) + } + else -> buildResponse(request, "", "text/plain", code = 404) + } + }, + store = store + ) + + whenever(htmlParser.parse(any(), eq(chapterUrl))).thenReturn(listOf(ContentElement.Image(imageUrl))) + + harness.loader.prefetch(chapterUrl, PrefetchMode.USER_REQUESTED) + store.clear(chapterUrl) + val sidecar = File(harness.htmlDownloadsDir, "${CacheKeyUtils.keyFor(chapterUrl)}.html.failed") + sidecar.writeText(imageUrl) // plain url, no timestamp — pre-migration format + + val attemptsBefore = imageRequests.get() + harness.loader.prefetch(chapterUrl, PrefetchMode.USER_REQUESTED) + + assertFalse("sidecar must be removed after migration", sidecar.exists()) + assertTrue( + "legacy entry without timestamp must trigger a fresh attempt", + imageRequests.get() > attemptsBefore + ) + } + @Test fun `prefetch retries on transient failures up to max attempts`() = runBlocking { val chapterUrl = "https://example.com/transient-fail" @@ -98,7 +224,19 @@ class WebContentLoaderPrefetchRetryTest { private fun createLoader( htmlParser: HtmlParser, interceptor: Interceptor - ): WebContentLoader { + ): WebContentLoader = createLoaderWithDirs(htmlParser, interceptor).loader + + private data class LoaderHarness( + val loader: WebContentLoader, + val htmlDownloadsDir: File, + val store: InMemoryPermanentFailureStore + ) + + private fun createLoaderWithDirs( + htmlParser: HtmlParser, + interceptor: Interceptor, + store: InMemoryPermanentFailureStore = InMemoryPermanentFailureStore() + ): LoaderHarness { val root = Files.createTempDirectory("prefetch-retry-test").toFile() val htmlCacheDir = File(root, "html_cache").apply { mkdirs() } val mediaCacheDir = File(root, "media_cache").apply { mkdirs() } @@ -107,7 +245,12 @@ class WebContentLoaderPrefetchRetryTest { val client = OkHttpClient.Builder() .addInterceptor(interceptor) .build() - return WebContentLoader(htmlParser, client, ImageCache(mediaCacheDir, mediaDownloadsDir), ImageDownloader(client), ParsedContentCache(), htmlCacheDir, htmlDownloadsDir) + val loader = WebContentLoader( + htmlParser, client, ImageCache(mediaCacheDir, mediaDownloadsDir), + ImageDownloader(client), ParsedContentCache(), htmlCacheDir, htmlDownloadsDir, + store + ) + return LoaderHarness(loader, htmlDownloadsDir, store) } private fun buildResponse( diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt index 26a8c97..62ada6d 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt @@ -697,7 +697,7 @@ class WebContentLoaderTest { .build() val imageCache = ImageCache(mediaCacheDir, mediaDownloadsDir) val imageDownloader = ImageDownloader(client) - return WebContentLoader(htmlParser, client, imageCache, imageDownloader, ParsedContentCache(), htmlCacheDir, htmlDownloadsDir) + return WebContentLoader(htmlParser, client, imageCache, imageDownloader, ParsedContentCache(), htmlCacheDir, htmlDownloadsDir, InMemoryPermanentFailureStore()) } private fun buildResponse( @@ -706,15 +706,29 @@ class WebContentLoaderTest { contentType: String, code: Int = 200 ): Response { + val payload = if (code == 200 && contentType.startsWith("image/")) { + val bytes = VALID_JPEG_HEADER + body.toByteArray() + // ImageIntegrity requires at least 64 bytes; pad with zeros if the fixture body is short. + if (bytes.size < 64) bytes + ByteArray(64 - bytes.size) else bytes + } else { + body.toByteArray() + } return Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(code) .message(if (code == 200) "OK" else "Error") - .body(body.toResponseBody(contentType.toMediaType())) + .body(payload.toResponseBody(contentType.toMediaType())) .build() } + private companion object { + // ImageIntegrity validates downloaded files by magic bytes. Test fixtures use string + // bodies like "image-body" that wouldn't pass; prepend a JPEG SOI/APP0 marker so the + // integrity check accepts them without requiring real image payloads. + private val VALID_JPEG_HEADER = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + } + private fun buildByteResponse( request: okhttp3.Request, body: ByteArray, diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index 275a39d..bb7c590 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -47,7 +47,8 @@ class LibraryViewModelTest { viewModel = LibraryViewModel( libraryRepository, contentRepository, - exploreRepository + exploreRepository, + io.aatricks.easyreader.work.NoOpChapterDownloadQueue() ) } @@ -90,7 +91,7 @@ class LibraryViewModelTest { ) whenever(libraryRepository.libraryItems).thenReturn(libraryItems) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) advanceUntilIdle() activeViewModel.toggleSelection(itemId) @@ -98,7 +99,7 @@ class LibraryViewModelTest { assertEquals(setOf(itemId), activeViewModel.uiState.value.selectedIds) - val restoredViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val restoredViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) advanceUntilIdle() assertTrue(restoredViewModel.uiState.value.selectedIds.isEmpty()) @@ -111,7 +112,7 @@ class LibraryViewModelTest { val item2 = LibraryItem(id = "id-2", title = "Novel 2", url = "https://example.com/novel-2") whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(listOf(item1, item2))) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) advanceUntilIdle() activeViewModel.selectItem(item1.id) @@ -133,7 +134,7 @@ class LibraryViewModelTest { val item2 = LibraryItem(id = "id-2", title = "Novel 2", url = "https://example.com/novel-2") whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(listOf(item1, item2))) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) advanceUntilIdle() activeViewModel.removeItemsImmediate(setOf(item1.id, item2.id)) @@ -163,7 +164,7 @@ class LibraryViewModelTest { @Test fun `toggle source expansion updates collapsed sources and persists`() = runTest { - val vm = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val vm = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) advanceUntilIdle() vm.toggleSourceExpansion("NovelFire") @@ -442,7 +443,7 @@ class LibraryViewModelTest { whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inspected) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) advanceUntilIdle() @@ -520,7 +521,7 @@ class LibraryViewModelTest { whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inspected) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) advanceUntilIdle() diff --git a/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt new file mode 100644 index 0000000..56a1989 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt @@ -0,0 +1,72 @@ +package io.aatricks.easyreader.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ImageIntegrityTest { + + @get:Rule val tempFolder = TemporaryFolder() + + @Test + fun `rejects zero-byte file`() { + val file = tempFolder.newFile("empty.jpg").apply { writeBytes(ByteArray(0)) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `rejects file below minimum size threshold`() { + val file = tempFolder.newFile("tiny.jpg").apply { writeBytes(byteArrayOf(0xFF.toByte(), 0xD8.toByte())) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts JPEG with SOI marker`() { + val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + ByteArray(64) + val file = tempFolder.newFile("ok.jpg").apply { writeBytes(payload) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts PNG with full 8-byte signature`() { + val signature = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ) + val file = tempFolder.newFile("ok.png").apply { writeBytes(signature + ByteArray(32)) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts WebP with RIFF and WEBP brand`() { + val webp = byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + 0, 0, 0, 0, + 'W'.code.toByte(), 'E'.code.toByte(), 'B'.code.toByte(), 'P'.code.toByte() + ) + ByteArray(16) + val file = tempFolder.newFile("ok.webp").apply { writeBytes(webp) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `rejects HTML payload masquerading as image`() { + val html = "Cloudflare challenge".toByteArray() + val file = tempFolder.newFile("html.jpg").apply { writeBytes(html) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts SVG`() { + val svg = "".toByteArray() + val file = tempFolder.newFile("ok.svg").apply { writeBytes(svg) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `rejects random unrecognized payload`() { + val payload = "this is not an image at all just random text".toByteArray() + val file = tempFolder.newFile("garbage.jpg").apply { writeBytes(payload) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt b/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt new file mode 100644 index 0000000..0c56b76 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt @@ -0,0 +1,198 @@ +package io.aatricks.easyreader.work + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf +import io.aatricks.easyreader.data.model.PrefetchMode +import io.aatricks.easyreader.data.model.PrefetchResult +import io.aatricks.easyreader.data.repository.ContentRepository +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class) +class ChapterDownloadWorkerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + @Test + fun `worker runs prefetch and reports success when chapter completes`() = runBlocking { + val chapterUrl = "https://example.com/work-success" + val contentRepository = mock() + // Inspect must indicate the chapter is incomplete so the worker actually runs the + // prefetch path instead of short-circuiting on the already-complete check. + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn( + PrefetchResult(url = chapterUrl, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false) + ) + whenever(contentRepository.prefetchWithProgress(eq(chapterUrl), eq(PrefetchMode.USER_REQUESTED), any())) + .thenReturn( + PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 5, + cachedImages = 5, + isComplete = true, + isPersistentDownload = true + ) + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to chapterUrl)) + .setWorkerFactory(workerFactoryWith(contentRepository)) + .build() + + val result = worker.doWork() + assertTrue("expected Success, got $result", result is ListenableWorker.Result.Success) + verify(contentRepository).beginUserDownload(chapterUrl) + verify(contentRepository).endUserDownload(chapterUrl) + } + + @Test + fun `worker short-circuits when chapter already complete`() = runBlocking { + val chapterUrl = "https://example.com/work-already-done" + val contentRepository = mock() + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn( + PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 4, + cachedImages = 4, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = false + ) + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to chapterUrl)) + .setWorkerFactory(workerFactoryWith(contentRepository)) + .build() + + val result = worker.doWork() + assertTrue("expected Success, got $result", result is ListenableWorker.Result.Success) + // Confirm we did NOT run the prefetch — in-process call had already finished it. + verify(contentRepository, org.mockito.kotlin.never()) + .prefetchWithProgress(eq(chapterUrl), eq(PrefetchMode.USER_REQUESTED), any()) + verify(contentRepository, org.mockito.kotlin.never()).beginUserDownload(chapterUrl) + } + + @Test + fun `worker returns retry when prefetch is retryable`() = runBlocking { + val chapterUrl = "https://example.com/work-retry" + val contentRepository = mock() + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn( + PrefetchResult(url = chapterUrl, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false) + ) + whenever(contentRepository.prefetchWithProgress(eq(chapterUrl), eq(PrefetchMode.USER_REQUESTED), any())) + .thenReturn( + PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 5, + cachedImages = 2, + isComplete = false, + isRetryable = true, + isPersistentDownload = true + ) + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to chapterUrl)) + .setWorkerFactory(workerFactoryWith(contentRepository)) + .build() + + val result = worker.doWork() + assertTrue("expected Retry, got $result", result is ListenableWorker.Result.Retry) + } + + @Test + fun `queue enqueueUniqueWork dedupes by chapter url`() = runBlocking { + val queue = WorkManagerChapterDownloadQueue(context) + val url = "https://example.com/work-dedup" + + queue.enqueue(url) + queue.enqueue(url) // KEEP policy: second call must coalesce, not enqueue twice. + + val infos = WorkManager.getInstance(context) + .getWorkInfosByTag(ChapterDownloadQueue.TAG_CHAPTER_DOWNLOAD) + .get() + assertEquals(1, infos.size) + } + + @Test + fun `queue cancel removes the enqueued work`() = runBlocking { + val queue = WorkManagerChapterDownloadQueue(context) + val url = "https://example.com/work-cancel" + + queue.enqueue(url) + queue.cancel(url) + + val infos = WorkManager.getInstance(context) + .getWorkInfosByTag(ChapterDownloadQueue.TAG_CHAPTER_DOWNLOAD) + .get() + val live = infos.filterNot { it.state == WorkInfo.State.CANCELLED || it.state == WorkInfo.State.SUCCEEDED } + assertEquals(0, live.size) + } + + @Test + fun `replaceExisting leaves at most one live work for the chapter`() { + val queue = WorkManagerChapterDownloadQueue(context) + val url = "https://example.com/work-replace" + + queue.enqueue(url) + queue.enqueue(url, replaceExisting = true) + + val infos = WorkManager.getInstance(context) + .getWorkInfosByTag(ChapterDownloadQueue.TAG_CHAPTER_DOWNLOAD) + .get() + // REPLACE policy must guarantee at most one live job for the chapter — under the + // synchronous executor either both finish SUCCEEDED, the first is CANCELLED, or the + // second is ENQUEUED. None of those leave two RUNNING/ENQUEUED at once. + val live = infos.count { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } + assertTrue("expected at most one live work, got $infos", live <= 1) + } + + private fun workerFactoryWith(contentRepository: ContentRepository): androidx.work.WorkerFactory { + return object : androidx.work.WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return if (workerClassName == ChapterDownloadWorker::class.java.name) { + ChapterDownloadWorker(appContext, workerParameters, contentRepository) + } else null + } + } + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/work/NoOpChapterDownloadQueue.kt b/app/src/test/java/io/aatricks/easyreader/work/NoOpChapterDownloadQueue.kt new file mode 100644 index 0000000..e690423 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/work/NoOpChapterDownloadQueue.kt @@ -0,0 +1,17 @@ +package io.aatricks.easyreader.work + +import io.aatricks.easyreader.data.model.PrefetchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Test-only no-op queue. Existing LibraryViewModel tests rely on the direct in-process + * prefetch path; this no-op lets them keep mocking ContentRepository.prefetchWithProgress + * without standing up a Robolectric WorkManager test harness. + */ +class NoOpChapterDownloadQueue : ChapterDownloadQueue { + override fun enqueue(url: String, replaceExisting: Boolean) = Unit + override fun cancel(url: String) = Unit + override fun observeChapter(url: String): Flow = flowOf(null) + override fun observeAll(): Flow> = flowOf(emptyMap()) +} diff --git a/app/src/test/java/io/aatricks/easyreader/work/TestApplication.kt b/app/src/test/java/io/aatricks/easyreader/work/TestApplication.kt new file mode 100644 index 0000000..78155bd --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/work/TestApplication.kt @@ -0,0 +1,9 @@ +package io.aatricks.easyreader.work + +import android.app.Application + +/** + * Minimal Application stand-in for Robolectric tests that need WorkManager but don't want + * to bootstrap Hilt. Use with `@Config(application = TestApplication::class)`. + */ +class TestApplication : Application() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9520ed..433ec97 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,12 +29,16 @@ robolectric = "4.12.1" androidxTestCore = "1.5.0" coreSplashscreen = "1.0.1" work = "2.9.1" +hiltWork = "1.2.0" detekt = "1.23.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork" } junit = { group = "junit", name = "junit", version.ref = "junit" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } From 04b2f68044e6cd6dab1409ac3af044182ee1782c Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 20 May 2026 22:14:57 -0400 Subject: [PATCH 03/19] Add opt-in for on-device AI summaries --- .../SummaryViewModelBenchmarkTest.kt | 16 ++-- .../data/local/PreferencesManager.kt | 10 ++- .../data/repository/SummaryService.kt | 7 ++ .../summary/DisabledSummaryEngine.kt | 2 + .../data/repository/summary/SummaryEngine.kt | 8 ++ .../ui/components/ChapterSummaryDropdown.kt | 46 +++++++++++- .../library/LibraryScreenListSections.kt | 6 +- .../ui/screens/settings/SettingsScreen.kt | 75 +++++++++++++++++++ .../ui/viewmodel/SummaryViewModel.kt | 66 ++++++++++++---- 9 files changed, 212 insertions(+), 24 deletions(-) diff --git a/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt b/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt index 13a24d3..3d72568 100644 --- a/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt +++ b/app/src/benchmark/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModelBenchmarkTest.kt @@ -1,5 +1,7 @@ package io.aatricks.easyreader.ui.viewmodel +import androidx.test.core.app.ApplicationProvider +import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.repository.SummaryService import io.aatricks.easyreader.data.repository.summary.DisabledSummaryEngine import kotlinx.coroutines.Dispatchers @@ -26,6 +28,7 @@ class SummaryViewModelBenchmarkTest { private val testDispatcher = StandardTestDispatcher() private lateinit var summaryEngine: DisabledSummaryEngine private lateinit var summaryService: SummaryService + private lateinit var preferencesManager: PreferencesManager private lateinit var viewModel: SummaryViewModel @Before @@ -33,7 +36,8 @@ class SummaryViewModelBenchmarkTest { Dispatchers.setMain(testDispatcher) summaryEngine = DisabledSummaryEngine() summaryService = SummaryService(summaryEngine) - viewModel = SummaryViewModel(summaryService) + preferencesManager = PreferencesManager(ApplicationProvider.getApplicationContext()) + viewModel = SummaryViewModel(summaryService, preferencesManager) } @After @@ -65,13 +69,15 @@ class SummaryViewModelBenchmarkTest { } @Test - fun `initializeSummaryService should fail gracefully with disabled engine`() = runTest { + fun `initializeSummaryService is a no-op when the build does not support AI`() = runTest { viewModel.initializeSummaryService() - + advanceUntilIdle() - + val state = viewModel.uiState.value + assertFalse(state.supportsAi) + assertFalse(state.isEnabled) assertFalse(state.isInitializing) - assertEquals("AI Summarization is disabled in this build.", state.error) + assertNull(state.error) } } 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 08de584..9682db9 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 @@ -144,7 +144,13 @@ class PreferencesManager @Inject constructor( get() = prefs.getString(KEY_ACCENT_THEME, io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name) ?: io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name set(value) = prefs.edit().putString(KEY_ACCENT_THEME, value).apply() - + + // Opt-in for AI summary model. False by default so the model is never + // downloaded unless the user explicitly enables the feature. + var aiSummaryEnabled: Boolean + get() = prefs.getBoolean(KEY_AI_SUMMARY_ENABLED, false) + set(value) = prefs.edit().putBoolean(KEY_AI_SUMMARY_ENABLED, value).apply() + // Clear all preferences fun clearAll() { prefs.edit().clear().apply() @@ -202,5 +208,7 @@ class PreferencesManager @Inject constructor( private const val KEY_PARAGRAPH_SPACING = "reader_paragraph_spacing" private const val KEY_READER_THEME = "reader_theme" private const val KEY_ACCENT_THEME = "accent_theme" + + private const val KEY_AI_SUMMARY_ENABLED = "ai_summary_enabled" } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/SummaryService.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/SummaryService.kt index af43cac..bc49514 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/SummaryService.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/SummaryService.kt @@ -22,6 +22,13 @@ class SummaryService @Inject constructor( private val WHITESPACE_REGEX = Regex("\\s+") } + /** + * Whether the active build flavor includes the on-device summarization + * engine. UI can use this to hide AI controls completely on the standard + * flavor while still showing them (and offering opt-in) on the AI flavor. + */ + fun supportsAi(): Boolean = summaryEngine.supportsAi + /** * Initialize the summary model (lazy loading) */ diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/summary/DisabledSummaryEngine.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/summary/DisabledSummaryEngine.kt index 0f7b643..344eac5 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/summary/DisabledSummaryEngine.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/summary/DisabledSummaryEngine.kt @@ -8,6 +8,8 @@ import javax.inject.Singleton */ @Singleton class DisabledSummaryEngine @Inject constructor() : SummaryEngine { + override val supportsAi: Boolean = false + override fun isAvailable(): Boolean = false override suspend fun initialize(): Result = diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/summary/SummaryEngine.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/summary/SummaryEngine.kt index 0b03caf..8115c0d 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/summary/SummaryEngine.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/summary/SummaryEngine.kt @@ -4,6 +4,14 @@ package io.aatricks.easyreader.data.repository.summary * Interface for AI summarization engines. */ interface SummaryEngine { + /** + * Whether this build supports on-device AI summarization at all. + * False for the standard flavor; true for the AI flavor regardless of + * whether the model has been downloaded yet. + */ + val supportsAi: Boolean + get() = true + /** * Check if the engine is available and ready to use. */ diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt index 5419144..d9201f9 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt @@ -25,7 +25,11 @@ fun ChapterSummaryDropdown( isGenerating: Boolean, onGenerateSummary: () -> Unit, onCancel: (() -> Unit)? = null, - isAvailable: Boolean = true, + aiSupportedInBuild: Boolean = true, + aiOptedIn: Boolean = true, + onEnableAi: (() -> Unit)? = null, + isInitializing: Boolean = false, + isReady: Boolean = aiSupportedInBuild && aiOptedIn, modifier: Modifier = Modifier ) { Surface( @@ -48,7 +52,7 @@ fun ChapterSummaryDropdown( Spacer(modifier = Modifier.height(4.dp)) when { - !isAvailable -> { + !aiSupportedInBuild -> { Text( text = "AI summaries aren't available in this build.", style = MaterialTheme.typography.bodyMedium, @@ -62,6 +66,44 @@ fun ChapterSummaryDropdown( ) } + !aiOptedIn -> { + Text( + text = "Enable AI summaries to generate on-device chapter recaps.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "The AI model is downloaded once (a few hundred MB) and then runs offline.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (onEnableAi != null) { + FilledTonalButton(onClick = onEnableAi) { + Text("Enable AI summaries") + } + } + } + + isInitializing && !isReady -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Downloading AI model…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + isGenerating -> { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt index 36ecb5d..6cff71a 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt @@ -559,7 +559,11 @@ private fun NovelChapterList( ChapterSummaryDropdown( summary = streamingSummary, isGenerating = summaryUiState.isGenerating && summaryUiState.activeChapterUrl == chapterUrl, - isAvailable = summaryViewModel.isServiceReady() || summaryUiState.isInitializing, + aiSupportedInBuild = summaryUiState.supportsAi, + aiOptedIn = summaryUiState.isEnabled, + onEnableAi = { summaryViewModel.setAiSummaryEnabled(true) }, + isInitializing = summaryUiState.isInitializing, + isReady = summaryViewModel.isServiceReady(), onGenerateSummary = { scope.launch { val result = readerViewModel.contentRepository.loadContent(chapterUrl) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt index af88175..da9b5b5 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -51,6 +52,7 @@ import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.ui.viewmodel.BackupViewModel import io.aatricks.easyreader.ui.viewmodel.LibraryViewModel import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel +import io.aatricks.easyreader.ui.viewmodel.SummaryViewModel import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -69,10 +71,13 @@ fun SettingsScreen( val snackbarHostState = remember { SnackbarHostState() } val libraryState by libraryViewModel.uiState.collectAsState() val backupStatus by backupViewModel.status.collectAsState() + val summaryViewModel: SummaryViewModel = hiltViewModel() + val summaryUiState by summaryViewModel.uiState.collectAsState() var cacheBytes by remember { mutableLongStateOf(-1L) } var showClearCacheDialog by remember { mutableStateOf(false) } var showClearLibraryDialog by remember { mutableStateOf(false) } + var showEnableAiDialog by remember { mutableStateOf(false) } var pendingSettingsImportUri by remember { mutableStateOf(null) } var pendingLibraryImportUri by remember { mutableStateOf(null) } var refreshKey by remember { mutableStateOf(0) } @@ -169,6 +174,49 @@ fun SettingsScreen( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + if (summaryUiState.supportsAi) { + SettingsSection(title = "AI features") { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Chapter summaries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (summaryUiState.isEnabled) { + "Model runs on-device. Disable to free memory." + } else { + "Downloads a small on-device model (a few hundred MB)." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.size(EasyReaderSpacing.sm)) + Switch( + checked = summaryUiState.isEnabled, + onCheckedChange = { wantEnabled -> + if (wantEnabled && !summaryUiState.isEnabled) { + showEnableAiDialog = true + } else if (!wantEnabled) { + summaryViewModel.setAiSummaryEnabled(false) + } + } + ) + } + if (summaryUiState.isInitializing) { + SettingsRow(title = "Status", subtitle = "Downloading model…") + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + SettingsSection(title = "Backup & restore") { val inProgress = backupStatus is BackupViewModel.OpStatus.InProgress SettingsRow( @@ -270,6 +318,33 @@ fun SettingsScreen( ) } + if (showEnableAiDialog) { + AlertDialog( + onDismissRequest = { showEnableAiDialog = false }, + title = { Text("Enable AI summaries?") }, + text = { + Text( + "This downloads a small on-device language model (a few hundred MB) " + + "the first time it is needed. After that it runs offline. " + + "You can disable AI summaries at any time." + ) + }, + confirmButton = { + FilledTonalButton(onClick = { + summaryViewModel.setAiSummaryEnabled(true) + showEnableAiDialog = false + }) { + Text("Download and enable") + } + }, + dismissButton = { + TextButton(onClick = { showEnableAiDialog = false }) { + Text("Cancel") + } + } + ) + } + if (showClearLibraryDialog) { AlertDialog( onDismissRequest = { showClearLibraryDialog = false }, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModel.kt index d8dd85d..74bc22d 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/SummaryViewModel.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.repository.SummaryService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,13 +18,21 @@ import javax.inject.Inject */ @HiltViewModel class SummaryViewModel @Inject constructor( - private val summaryService: SummaryService -) : BaseViewModel(SummaryUiState()) { - + private val summaryService: SummaryService, + private val preferencesManager: PreferencesManager +) : BaseViewModel( + SummaryUiState( + supportsAi = summaryService.supportsAi(), + isEnabled = summaryService.supportsAi() && preferencesManager.aiSummaryEnabled + ) +) { + private val TAG = "SummaryViewModel" - + // UI State data class SummaryUiState( + val supportsAi: Boolean = false, + val isEnabled: Boolean = false, val isInitializing: Boolean = false, val isGenerating: Boolean = false, val error: String? = null, @@ -31,14 +40,41 @@ class SummaryViewModel @Inject constructor( val activeChapterUrl: String? = null, val summariesCache: Map = emptyMap() // chapterUrl -> summary ) - + + /** + * Toggle the AI summary opt-in. Enabling triggers a download/initialize; + * disabling releases the in-memory engine. Persists across launches. + */ + fun setAiSummaryEnabled(enabled: Boolean): Unit { + if (!_uiState.value.supportsAi) return + if (_uiState.value.isEnabled == enabled) return + preferencesManager.aiSummaryEnabled = enabled + updateState { it.copy(isEnabled = enabled, error = null) } + if (enabled) { + initializeSummaryService() + } else { + summaryService.cancelGeneration() + summaryService.release() + updateState { + it.copy( + isInitializing = false, + isGenerating = false, + activeChapterUrl = null, + currentSummary = null + ) + } + } + } + /** * Initialize the summary service (loads AI model) */ fun initializeSummaryService(): Unit { + val state = _uiState.value + if (!state.supportsAi || !state.isEnabled) return viewModelScope.launch { updateState { it.copy(isInitializing = true, error = null) } - + summaryService.initialize() .onSuccess { Log.d(TAG, "Summary service initialized successfully") @@ -51,7 +87,7 @@ class SummaryViewModel @Inject constructor( } } } - + /** * Generate a summary for a chapter */ @@ -66,7 +102,7 @@ class SummaryViewModel @Inject constructor( onComplete(cached) return } - + viewModelScope.launch { updateState { it.copy( isGenerating = true, @@ -74,7 +110,7 @@ class SummaryViewModel @Inject constructor( error = null, currentSummary = null ) } - + val sb = StringBuilder() summaryService.generateSummary(chapterTitle, content, onProgress = { token -> sb.append(token) @@ -95,7 +131,7 @@ class SummaryViewModel @Inject constructor( val updatedCache = _uiState.value.summariesCache.toMutableMap().apply { put(chapterUrl, summary) } - + updateState { it.copy( isGenerating = false, activeChapterUrl = null, @@ -118,17 +154,17 @@ class SummaryViewModel @Inject constructor( summaryService.cancelGeneration() updateState { it.copy(isGenerating = false, activeChapterUrl = null) } } - + fun getCachedSummary(chapterUrl: String): String? = _uiState.value.summariesCache[chapterUrl] - + fun clearError(): Unit { updateState { it.copy(error = null) } } - + fun isServiceReady(): Boolean = summaryService.isReady() - + override fun onCleared(): Unit { super.onCleared() summaryService.release() } -} \ No newline at end of file +} From 8418d32becdae9e4ff786928ce741c9c308e09fb Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 20 May 2026 22:19:52 -0400 Subject: [PATCH 04/19] Disable default WorkManager startup Prevent WorkManagerInitializer from auto-initializing so the app's Configuration.Provider controls WorkManager initialization on demand --- app/src/main/AndroidManifest.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 957f471..89f8b6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,6 +72,19 @@ + + + + + \ No newline at end of file From e83a87523b6263af71f141d47a58ef5ed568e4de Mon Sep 17 00:00:00 2001 From: Aatricks Date: Thu, 21 May 2026 03:06:40 -0400 Subject: [PATCH 05/19] Persist reader progress by element key Replace lastReadOffset with lastReadElementKey and introduce FRACTION_UNKNOWN sentinel for unmeasured intra-item offsets. Bump Room schema to v8 and backup schema to v2, add MIGRATION_7_8, and update DAO, backup, viewmodel, UI, and tests accordingly --- .../8.json | 280 ++++++++++++++ .../easyreader/data/backup/BackupSchema.kt | 8 +- .../data/backup/LibraryBackupManager.kt | 8 +- .../easyreader/data/local/AppDatabase.kt | 68 +++- .../easyreader/data/local/LibraryDao.kt | 4 +- .../easyreader/data/model/LibraryItem.kt | 37 +- .../data/repository/LibraryRepository.kt | 26 +- .../aatricks/easyreader/di/DatabaseModule.kt | 3 +- .../ui/screens/reader/ReaderContentArea.kt | 335 ++++++++--------- .../screens/reader/ReaderContentRenderers.kt | 9 +- .../easyreader/ui/util/ReaderRestore.kt | 22 -- .../ui/viewmodel/ReaderProgressController.kt | 343 +++++++++--------- .../ui/viewmodel/ReaderViewModel.kt | 176 +++++---- .../data/local/AppDatabaseMigrationTest.kt | 279 +++++++++----- .../data/repository/LibraryRepositoryTest.kt | 21 +- .../screens/ReaderContentAreaLifecycleTest.kt | 66 ---- .../easyreader/ui/util/ReaderRestoreTest.kt | 43 --- .../viewmodel/ReaderProgressControllerTest.kt | 207 ++++++++--- .../ui/viewmodel/ReaderViewModelTest.kt | 158 ++++---- 19 files changed, 1250 insertions(+), 843 deletions(-) create mode 100644 app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json delete mode 100644 app/src/main/java/io/aatricks/easyreader/ui/util/ReaderRestore.kt delete mode 100644 app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderContentAreaLifecycleTest.kt delete mode 100644 app/src/test/java/io/aatricks/easyreader/ui/util/ReaderRestoreTest.kt diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json new file mode 100644 index 0000000..698e19e --- /dev/null +++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/8.json @@ -0,0 +1,280 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "a7814b493bd617a88fe851c538ddd6aa", + "entities": [ + { + "tableName": "library_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadElementKey` TEXT NOT NULL, `lastReadOffsetFraction` REAL NOT NULL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCurrentlyReading", + "columnName": "isCurrentlyReading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentChapter", + "columnName": "currentChapter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentChapterUrl", + "columnName": "currentChapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalChapters", + "columnName": "totalChapters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "dateAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloading", + "columnName": "isDownloading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrollPosition", + "columnName": "lastScrollPosition", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lastReadIndex", + "columnName": "lastReadIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadElementKey", + "columnName": "lastReadElementKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastReadOffsetFraction", + "columnName": "lastReadOffsetFraction", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hasUpdates", + "columnName": "hasUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterSummaries", + "columnName": "chapterSummaries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseTitle", + "columnName": "baseTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readingMode", + "columnName": "readingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseNovelUrl", + "columnName": "baseNovelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_library_items_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_library_items_baseTitle", + "unique": false, + "columnNames": [ + "baseTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)" + }, + { + "name": "index_library_items_isCurrentlyReading", + "unique": false, + "columnNames": [ + "isCurrentlyReading" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)" + }, + { + "name": "index_library_items_lastRead", + "unique": false, + "columnNames": [ + "lastRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)" + } + ] + }, + { + "tableName": "chapter_image_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))", + "fields": [ + { + "fieldPath": "chapterUrl", + "columnName": "chapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptMs", + "columnName": "lastAttemptMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "httpStatusCode", + "columnName": "httpStatusCode", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chapterUrl", + "imageUrl" + ] + }, + "indices": [ + { + "name": "index_chapter_image_state_chapterUrl", + "unique": false, + "columnNames": [ + "chapterUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)" + }, + { + "name": "index_chapter_image_state_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a7814b493bd617a88fe851c538ddd6aa')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt index 9e13f66..5c83c14 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt @@ -2,7 +2,7 @@ package io.aatricks.easyreader.data.backup import kotlinx.serialization.Serializable -const val BACKUP_SCHEMA_VERSION = 1 +const val BACKUP_SCHEMA_VERSION = 2 const val MANIFEST_ENTRY = "manifest.json" const val EPUB_ENTRY_PREFIX = "epubs/" @@ -50,8 +50,12 @@ data class LibraryItemBackup( val isDownloading: Boolean = false, val lastScrollPosition: Float, val lastReadIndex: Int, - val lastReadOffset: Int, + val lastReadElementKey: String = "", val lastReadOffsetFraction: Float? = null, + // Legacy schema-v1 raw-pixel anchor. Kept readable so v1 backups still parse; we use it as a + // last-resort hint (the post-unification restore path derives index from percent and ignores + // pixel offsets, but recording it lets us spot v1 imports in logs if needed). + val lastReadOffset: Int = 0, val hasUpdates: Boolean = false, val chapterSummaries: Map = emptyMap(), val baseTitle: String, diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt index 1a9243b..2169288 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt @@ -208,8 +208,8 @@ private fun LibraryItem.toBackup(bundledPath: String?): LibraryItemBackup = Libr isDownloading = isDownloading, lastScrollPosition = lastScrollPosition, lastReadIndex = lastReadIndex, - lastReadOffset = lastReadOffset, - lastReadOffsetFraction = lastReadOffsetFraction, + lastReadElementKey = lastReadElementKey, + lastReadOffsetFraction = lastReadOffsetFraction.takeUnless { it == io.aatricks.easyreader.data.model.FRACTION_UNKNOWN }, hasUpdates = hasUpdates, chapterSummaries = chapterSummaries, baseTitle = baseTitle, @@ -237,8 +237,8 @@ private fun LibraryItemBackup.toEntity(rewrittenUrl: String, fileVerified: Boole isDownloading = false, lastScrollPosition = lastScrollPosition, lastReadIndex = lastReadIndex, - lastReadOffset = lastReadOffset, - lastReadOffsetFraction = lastReadOffsetFraction, + lastReadElementKey = lastReadElementKey, + lastReadOffsetFraction = lastReadOffsetFraction ?: io.aatricks.easyreader.data.model.FRACTION_UNKNOWN, hasUpdates = hasUpdates, chapterSummaries = chapterSummaries, baseTitle = baseTitle.ifBlank { title }, diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt index 6f5e9a3..623536a 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt @@ -10,7 +10,7 @@ import io.aatricks.easyreader.data.model.LibraryItem @Database( entities = [LibraryItem::class, ChapterImageStateEntity::class], - version = 7, + version = 8, exportSchema = true ) @TypeConverters(Converters::class) @@ -217,5 +217,71 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("CREATE INDEX IF NOT EXISTS index_chapter_image_state_status ON chapter_image_state (status)") } } + + /** + * Schema unification for reading position: + * - Drop unused `lastReadOffset` (raw px — meaningless across reflow / item resize). + * - Add `lastReadElementKey` (stable per-element anchor; "" = unset). + * - Make `lastReadOffsetFraction` NOT NULL with sentinel -1.0 (= unknown), backfilling NULL rows. + */ + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE library_items_new ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + timestamp INTEGER NOT NULL, + progress INTEGER NOT NULL, + isCurrentlyReading INTEGER NOT NULL, + currentChapter TEXT NOT NULL, + currentChapterUrl TEXT NOT NULL, + totalChapters INTEGER NOT NULL, + contentType TEXT NOT NULL, + dateAdded INTEGER NOT NULL, + lastRead INTEGER NOT NULL, + isDownloading INTEGER NOT NULL, + lastScrollPosition REAL NOT NULL, + lastReadIndex INTEGER NOT NULL, + lastReadElementKey TEXT NOT NULL DEFAULT '', + lastReadOffsetFraction REAL NOT NULL DEFAULT -1, + hasUpdates INTEGER NOT NULL, + chapterSummaries TEXT NOT NULL, + baseTitle TEXT NOT NULL, + readingMode TEXT NOT NULL, + baseNovelUrl TEXT NOT NULL, + sourceName TEXT NOT NULL, + isDownloaded INTEGER NOT NULL DEFAULT 0, + downloadedAt INTEGER + ) + """.trimIndent()) + + db.execSQL(""" + INSERT INTO library_items_new ( + id, title, url, timestamp, progress, isCurrentlyReading, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, + lastReadIndex, lastReadElementKey, lastReadOffsetFraction, hasUpdates, + chapterSummaries, baseTitle, readingMode, baseNovelUrl, sourceName, + isDownloaded, downloadedAt + ) SELECT + id, title, url, timestamp, progress, isCurrentlyReading, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, + lastReadIndex, '', COALESCE(lastReadOffsetFraction, -1.0), hasUpdates, + chapterSummaries, baseTitle, readingMode, baseNovelUrl, sourceName, + isDownloaded, downloadedAt + FROM library_items + """.trimIndent()) + + db.execSQL("DROP TABLE library_items") + db.execSQL("ALTER TABLE library_items_new RENAME TO library_items") + + db.execSQL("CREATE UNIQUE INDEX index_library_items_url ON library_items (url)") + db.execSQL("CREATE INDEX index_library_items_baseTitle ON library_items (baseTitle)") + db.execSQL("CREATE INDEX index_library_items_isCurrentlyReading ON library_items (isCurrentlyReading)") + db.execSQL("CREATE INDEX index_library_items_lastRead ON library_items (lastRead)") + } + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt index ed9780d..601bde8 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt @@ -58,10 +58,10 @@ interface LibraryDao { @Query("DELETE FROM library_items") suspend fun deleteAllItems() - @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadOffset = 0, lastReadOffsetFraction = NULL, lastRead = :timestamp WHERE id = :id") + @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadElementKey = '', lastReadOffsetFraction = -1, lastRead = :timestamp WHERE id = :id") suspend fun resetProgress(id: String, timestamp: Long = System.currentTimeMillis()) - @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadOffset = 0, lastReadOffsetFraction = NULL WHERE baseTitle = :baseTitle") + @Query("UPDATE library_items SET progress = 0, lastScrollPosition = 0, lastReadIndex = 0, lastReadElementKey = '', lastReadOffsetFraction = -1 WHERE baseTitle = :baseTitle") suspend fun resetProgressByBaseTitle(baseTitle: String) @Transaction diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt index 51bc08c..843adf4 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt @@ -6,9 +6,11 @@ import androidx.room.PrimaryKey import kotlinx.serialization.Serializable /** - * Data class representing a library item in the Novel Scraper app. - * Immutable by default with validation in init block. + * Sentinel used in `lastReadOffsetFraction` to mean "intra-item position not yet measured". + * Keeps the column NOT NULL while preserving the "unknown" branch in restore logic. */ +const val FRACTION_UNKNOWN: Float = -1f + @Serializable @Entity( tableName = "library_items", @@ -36,8 +38,8 @@ data class LibraryItem( val isDownloading: Boolean = false, val lastScrollPosition: Float = 0f, val lastReadIndex: Int = 0, - val lastReadOffset: Int = 0, - val lastReadOffsetFraction: Float? = null, + val lastReadElementKey: String = "", + val lastReadOffsetFraction: Float = FRACTION_UNKNOWN, val hasUpdates: Boolean = false, val chapterSummaries: Map = emptyMap(), val baseTitle: String = "", @@ -53,34 +55,15 @@ data class LibraryItem( require(progress in 0..100) { "Progress must be between 0 and 100, got: $progress" } require(timestamp > 0) { "Timestamp must be positive" } } - - /** - * Creates a copy of this LibraryItem with updated progress. - * Ensures progress stays within valid range (0-100). - */ + fun withProgress(newProgress: Int): LibraryItem { val clampedProgress = newProgress.coerceIn(0, 100) return copy(progress = clampedProgress) } - - /** - * Creates a copy marking this item as currently reading. - * Typically used when opening a chapter. - */ + fun markAsReading(): LibraryItem = copy(isCurrentlyReading = true) - - /** - * Creates a copy marking this item as not currently reading. - */ + fun markAsNotReading(): LibraryItem = copy(isCurrentlyReading = false) - - /** - * Checks if the item has been started (progress > 0). - */ + fun isStarted(): Boolean = progress > 0 - - /** - * Checks if the item is completed (progress == 100). - */ - fun isCompleted(): Boolean = progress == 100 } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt index 27317d3..5de9cd1 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt @@ -10,7 +10,6 @@ import io.aatricks.easyreader.data.model.ReadingMode import io.aatricks.easyreader.data.model.hasFinishedProgress import io.aatricks.easyreader.util.FieldUpdate import io.aatricks.easyreader.util.resolve -import io.aatricks.easyreader.util.resolveNullable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -166,7 +165,7 @@ class LibraryRepository @Inject constructor( currentChapterUrl: String? = null, lastScrollProgress: Float? = null, lastReadIndex: Int? = null, - lastReadOffset: Int? = null, + lastReadElementKey: String? = null, lastReadOffsetFraction: Float? = null ): Unit { saveProgressExplicitAsync( @@ -176,7 +175,7 @@ class LibraryRepository @Inject constructor( currentChapterUrl = currentChapterUrl?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastScrollProgress = lastScrollProgress?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastReadIndex = lastReadIndex?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, - lastReadOffset = lastReadOffset?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, + lastReadElementKey = lastReadElementKey?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastReadOffsetFraction = lastReadOffsetFraction?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged ) } @@ -188,8 +187,8 @@ class LibraryRepository @Inject constructor( currentChapterUrl: FieldUpdate = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate = FieldUpdate.Unchanged, - lastReadOffset: FieldUpdate = FieldUpdate.Unchanged, - lastReadOffsetFraction: FieldUpdate = FieldUpdate.Unchanged + lastReadElementKey: FieldUpdate = FieldUpdate.Unchanged, + lastReadOffsetFraction: FieldUpdate = FieldUpdate.Unchanged ): Unit { repositoryScope.launch { updateProgressExplicit( @@ -199,7 +198,7 @@ class LibraryRepository @Inject constructor( currentChapterUrl, lastScrollProgress, lastReadIndex, - lastReadOffset, + lastReadElementKey, lastReadOffsetFraction ) } @@ -212,7 +211,7 @@ class LibraryRepository @Inject constructor( currentChapterUrl: String? = null, lastScrollProgress: Float? = null, lastReadIndex: Int? = null, - lastReadOffset: Int? = null, + lastReadElementKey: String? = null, lastReadOffsetFraction: Float? = null ): Boolean = updateProgressExplicit( itemId = itemId, @@ -221,7 +220,7 @@ class LibraryRepository @Inject constructor( currentChapterUrl = currentChapterUrl?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastScrollProgress = lastScrollProgress?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastReadIndex = lastReadIndex?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, - lastReadOffset = lastReadOffset?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, + lastReadElementKey = lastReadElementKey?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged, lastReadOffsetFraction = lastReadOffsetFraction?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Unchanged ) @@ -232,8 +231,8 @@ class LibraryRepository @Inject constructor( currentChapterUrl: FieldUpdate = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate = FieldUpdate.Unchanged, - lastReadOffset: FieldUpdate = FieldUpdate.Unchanged, - lastReadOffsetFraction: FieldUpdate = FieldUpdate.Unchanged + lastReadElementKey: FieldUpdate = FieldUpdate.Unchanged, + lastReadOffsetFraction: FieldUpdate = FieldUpdate.Unchanged ): Boolean = progressMutex.withLock { runRepoCatching("Failed to update progress", false) { libraryDao.getItemById(itemId)?.let { item -> @@ -243,8 +242,11 @@ class LibraryRepository @Inject constructor( currentChapterUrl = currentChapterUrl.resolve(item.currentChapterUrl, ""), lastScrollPosition = lastScrollProgress.resolve(item.lastScrollPosition, 0f), lastReadIndex = lastReadIndex.resolve(item.lastReadIndex, 0), - lastReadOffset = lastReadOffset.resolve(item.lastReadOffset, 0), - lastReadOffsetFraction = lastReadOffsetFraction.resolveNullable(item.lastReadOffsetFraction), + lastReadElementKey = lastReadElementKey.resolve(item.lastReadElementKey, ""), + lastReadOffsetFraction = lastReadOffsetFraction.resolve( + item.lastReadOffsetFraction, + io.aatricks.easyreader.data.model.FRACTION_UNKNOWN + ), lastRead = System.currentTimeMillis() ) libraryDao.insertItem(updated) diff --git a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt index 2e1fb32..e9f390f 100644 --- a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt +++ b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt @@ -29,7 +29,8 @@ object DatabaseModule { AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, - AppDatabase.MIGRATION_6_7 + AppDatabase.MIGRATION_6_7, + AppDatabase.MIGRATION_7_8 ) .build() } 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 5fa8800..89e0488 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 @@ -1,37 +1,21 @@ package io.aatricks.easyreader.ui.screens import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -41,38 +25,34 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontFamily -import io.aatricks.easyreader.ui.util.toFontFamily -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import io.aatricks.easyreader.data.model.ChapterContent import io.aatricks.easyreader.data.model.ContentElement -import io.aatricks.easyreader.ui.components.BottomNavigationBar -import io.aatricks.easyreader.ui.components.ReaderImageView +import io.aatricks.easyreader.data.model.FRACTION_UNKNOWN import io.aatricks.easyreader.ui.components.TopInfoBar -import io.aatricks.easyreader.ui.util.resolveRestoreOffset +import io.aatricks.easyreader.ui.components.BottomNavigationBar +import io.aatricks.easyreader.ui.util.toFontFamily +import io.aatricks.easyreader.ui.viewmodel.ReaderProgressController.Companion.MIN_STABLE_ITEM_SIZE_PX import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel -import kotlinx.coroutines.launch +import io.aatricks.easyreader.ui.viewmodel.stableContentElementKey import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlin.math.abs +private const val RESTORE_SMOKE_CHECK_DELAY_MS = 500L +private const val RESTORE_PERCENT_TOLERANCE = 5f + @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable internal fun ContentArea( @@ -91,12 +71,9 @@ internal fun ContentArea( isManhwaByUrl || (content.getImageCount() > content.getTextCount() && content.getImageCount() > 2) } - val listState = key(content.url) { - rememberLazyListState( - initialFirstVisibleItemIndex = uiState.scrollIndex, - initialFirstVisibleItemScrollOffset = uiState.scrollOffset - ) - } + // No init values — single restore path through LaunchedEffect below. Initial values would + // race with the LaunchedEffect and create the "two paths, one of them silently wrong" bug. + val listState = key(content.url) { rememberLazyListState() } val pagerState = key(content.url) { rememberPagerState( @@ -108,7 +85,6 @@ internal fun ContentArea( } val requestedIndices = remember(content.url) { mutableSetOf() } - var lastAppliedRestoreOffset by remember(content.url) { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current val coroutineScope = rememberCoroutineScope() @@ -119,54 +95,62 @@ internal fun ContentArea( } } - LaunchedEffect(content.url, uiState.isPagedMode, uiState.pendingRestoreOffsetFraction, uiState.scrollIndex) { - if (uiState.isPagedMode || uiState.pendingRestoreOffsetFraction == null || content.paragraphs.isEmpty()) { + // ─── 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) { + if (content.paragraphs.isEmpty()) 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) } return@LaunchedEffect } - snapshotFlow { - val visibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == uiState.scrollIndex } - visibleItem?.size ?: 0 + // From-bottom navigation always seeks the final item end. + if (uiState.targetScrollPosition == 100f) { + runCatching { listState.scrollToItem(content.paragraphs.lastIndex, Int.MAX_VALUE) } + return@LaunchedEffect } - .collect { itemSize -> - if (itemSize <= 0) return@collect - - val targetIndex = uiState.scrollIndex.coerceIn(0, content.paragraphs.lastIndex) - val targetOffset = resolveRestoreOffset( - savedOffsetPx = uiState.scrollOffset, - savedOffsetFraction = uiState.pendingRestoreOffsetFraction, - itemSizePx = itemSize - ) - val currentOffset = listState.firstVisibleItemScrollOffset - val isAlreadyApplied = listState.firstVisibleItemIndex == targetIndex && - abs(currentOffset - targetOffset) <= 2 && - lastAppliedRestoreOffset == targetOffset - - if (!isAlreadyApplied) { - listState.scrollToItem(targetIndex, targetOffset) - lastAppliedRestoreOffset = targetOffset - } - } - } - LaunchedEffect(uiState.seekTrigger) { - if (content.paragraphs.isNotEmpty() && uiState.seekTrigger > 0L) { - val targetIndex = uiState.scrollIndex - val targetOffset = uiState.scrollOffset - - if (uiState.isPagedMode) { - val page = targetIndex.coerceIn(0, content.paragraphs.size - 1) - pagerState.scrollToPage(page) - } else if (targetIndex >= 0) { - runCatching { - listState.scrollToItem(targetIndex, targetOffset) - }.onFailure { - val totalItems = content.paragraphs.size - val percent = uiState.scrollPosition.coerceIn(0f, 100f) / 100f - val index = (percent * totalItems).toInt().coerceIn(0, totalItems - 1) - listState.scrollToItem(index, 0) - } - } + // Land at the item first so the LazyList composes it. Offset comes after measurement. + runCatching { listState.scrollToItem(targetIndex, 0) } + + if (targetFraction == null || targetFraction == 0f) { + return@LaunchedEffect + } + + // Wait for the target item to reach a meaningful size — async image loads pass through + // a placeholder height first, and applying the fraction against the placeholder + // produces a meaningless offset. + val itemSize = snapshotFlow { + listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == targetIndex } + ?.size ?: 0 + }.first { it >= MIN_STABLE_ITEM_SIZE_PX } + + val targetOffsetPx = (itemSize * targetFraction).toInt().coerceIn(0, itemSize) + runCatching { listState.scrollToItem(targetIndex, targetOffsetPx) } + + // Self-heal smoke check — verify after layout has settled. Skip if the user has already + // taken the wheel: yanking them away from their own scroll is worse than tolerating a + // small restore mismatch. + kotlinx.coroutines.delay(RESTORE_SMOKE_CHECK_DELAY_MS) + if (readerViewModel.hasUserInteractedSinceLoad) return@LaunchedEffect + 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) } } } @@ -174,13 +158,18 @@ internal fun ContentArea( LaunchedEffect(pagerState.currentPage) { val totalItems = content.paragraphs.size val currentItem = pagerState.currentPage + val currentKey = content.paragraphs.getOrNull(currentItem) + ?.let { stableContentElementKey(content.url, currentItem, it) } + ?: "" readerViewModel.updateScrollPosition( scrollOffset = currentItem.toFloat(), maxScrollOffset = (totalItems - 1).coerceAtLeast(0).toFloat(), viewportHeight = 0f, index = currentItem, - offset = 0 + offsetFraction = 0f, + elementKey = currentKey, + firstVisibleItemSize = Int.MAX_VALUE ) } } else { @@ -189,39 +178,18 @@ internal fun ContentArea( if (event != Lifecycle.Event.ON_PAUSE && event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver if (uiState.isPagedMode) return@LifecycleEventObserver - val layoutInfo = listState.layoutInfo - val firstItem = layoutInfo.visibleItemsInfo.firstOrNull() - val snapshot = firstItem?.let { - calculateReaderScrollSnapshot( - firstVisibleItemIndex = listState.firstVisibleItemIndex, - firstVisibleItemMeasuredIndex = it.index, - firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset, - canScrollForward = listState.canScrollForward, - totalItemsCount = layoutInfo.totalItemsCount, - viewportHeightPx = layoutInfo.viewportSize.height, - firstVisibleItemSize = it.size - ) - } - - flushReaderLifecycleProgress( - snapshot = snapshot, - updateScrollPosition = { flushSnapshot -> - readerViewModel.updateScrollPosition( - scrollOffset = flushSnapshot.scrollOffset, - maxScrollOffset = flushSnapshot.maxScrollOffset, - viewportHeight = flushSnapshot.viewportHeightInItems, - index = flushSnapshot.index, - offset = flushSnapshot.offset, - canScrollForward = flushSnapshot.canScrollForward, - firstVisibleItemSize = flushSnapshot.firstVisibleItemSize - ) - }, - persistProgress = { - coroutineScope.launch { - readerViewModel.persistLifecycleProgress() - } - } + val snapshot = buildScrollSnapshot(listState, content) ?: return@LifecycleEventObserver + readerViewModel.updateScrollPosition( + scrollOffset = snapshot.scrollOffset, + maxScrollOffset = snapshot.maxScrollOffset, + viewportHeight = snapshot.viewportHeightInItems, + index = snapshot.index, + offsetFraction = snapshot.offsetFraction, + elementKey = snapshot.elementKey, + canScrollForward = snapshot.canScrollForward, + firstVisibleItemSize = snapshot.firstVisibleItemSize ) + coroutineScope.launch { readerViewModel.persistLifecycleProgress() } } lifecycleOwner.lifecycle.addObserver(observer) @@ -240,29 +208,14 @@ internal fun ContentArea( } .conflate() .collect { - if (content.paragraphs.isEmpty()) return@collect - - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - if (visibleItems.isEmpty()) return@collect - - val firstItem = visibleItems.first() - val snapshot = calculateReaderScrollSnapshot( - firstVisibleItemIndex = listState.firstVisibleItemIndex, - firstVisibleItemMeasuredIndex = firstItem.index, - firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset, - canScrollForward = listState.canScrollForward, - totalItemsCount = layoutInfo.totalItemsCount, - viewportHeightPx = layoutInfo.viewportSize.height, - firstVisibleItemSize = firstItem.size - ) ?: return@collect - + val snapshot = buildScrollSnapshot(listState, content) ?: return@collect readerViewModel.updateScrollPosition( scrollOffset = snapshot.scrollOffset, maxScrollOffset = snapshot.maxScrollOffset, viewportHeight = snapshot.viewportHeightInItems, index = snapshot.index, - offset = snapshot.offset, + offsetFraction = snapshot.offsetFraction, + elementKey = snapshot.elementKey, canScrollForward = snapshot.canScrollForward, firstVisibleItemSize = snapshot.firstVisibleItemSize ) @@ -372,6 +325,73 @@ internal fun ContentArea( } } +// ─── Restore helpers ──────────────────────────────────────────────────────── + +private fun resolveRestoreIndex( + content: ChapterContent, + uiState: ReaderViewModel.ReaderUiState +): Int { + val key = uiState.restoreElementKey + if (key.isNotEmpty()) { + content.paragraphs.forEachIndexed { idx, element -> + if (stableContentElementKey(content.url, idx, element) == key) return idx + } + } + return uiState.scrollIndex +} + +private fun computeVisiblePercent(listState: LazyListState, totalItems: Int): Float? { + if (totalItems <= 0) return null + val firstItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null + if (firstItem.size <= 0) return null + val viewportHeight = listState.layoutInfo.viewportSize.height.toFloat() + val viewportInItems = viewportHeight / firstItem.size + val currentPos = firstItem.index.toFloat() + (listState.firstVisibleItemScrollOffset.toFloat() / firstItem.size) + val maxPos = (totalItems - 1).coerceAtLeast(0).toFloat() + viewportInItems + val denom = (maxPos - viewportInItems).coerceAtLeast(0.0001f) + return ((currentPos / denom) * 100f).coerceIn(0f, 100f) +} + +private fun buildScrollSnapshot(listState: LazyListState, content: ChapterContent): ReaderScrollSnapshot? { + if (content.paragraphs.isEmpty()) return null + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val firstItem = visibleItems.firstOrNull() ?: return null + if (firstItem.size <= 0) return null + + val itemSize = firstItem.size.coerceAtLeast(1) + val currentScrollOffset = firstItem.index.toFloat() + + (listState.firstVisibleItemScrollOffset.toFloat() / itemSize.toFloat()) + val viewportHeightInItems = layoutInfo.viewportSize.height.toFloat() / itemSize.toFloat() + val maxScrollOffset = (layoutInfo.totalItemsCount - 1).coerceAtLeast(0).toFloat() + viewportHeightInItems + val offsetFraction = (listState.firstVisibleItemScrollOffset.toFloat() / itemSize.toFloat()).coerceIn(0f, 1f) + val elementKey = content.paragraphs.getOrNull(firstItem.index) + ?.let { stableContentElementKey(content.url, firstItem.index, it) } + ?: "" + + return ReaderScrollSnapshot( + scrollOffset = currentScrollOffset, + maxScrollOffset = maxScrollOffset, + viewportHeightInItems = viewportHeightInItems, + index = listState.firstVisibleItemIndex, + offsetFraction = offsetFraction, + elementKey = elementKey, + canScrollForward = listState.canScrollForward, + firstVisibleItemSize = itemSize + ) +} + +internal data class ReaderScrollSnapshot( + val scrollOffset: Float, + val maxScrollOffset: Float, + val viewportHeightInItems: Float, + val index: Int, + val offsetFraction: Float, + val elementKey: String, + val canScrollForward: Boolean, + val firstVisibleItemSize: Int +) + @Composable private fun EdgeNavigationHint(atTop: Boolean, atBottom: Boolean) { if (atTop) { @@ -389,7 +409,7 @@ private fun EdgeNavigationHint(atTop: Boolean, atBottom: Boolean) { @Composable private fun EdgeHintChip( text: String, - icon: androidx.compose.ui.graphics.vector.ImageVector + icon: ImageVector ) { androidx.compose.material3.Surface( modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), @@ -417,55 +437,6 @@ private fun EdgeHintChip( } } -internal data class ReaderScrollSnapshot( - val scrollOffset: Float, - val maxScrollOffset: Float, - val viewportHeightInItems: Float, - val index: Int, - val offset: Int, - val canScrollForward: Boolean, - val firstVisibleItemSize: Int -) - -internal fun calculateReaderScrollSnapshot( - firstVisibleItemIndex: Int, - firstVisibleItemMeasuredIndex: Int, - firstVisibleItemScrollOffset: Int, - canScrollForward: Boolean, - totalItemsCount: Int, - viewportHeightPx: Int, - firstVisibleItemSize: Int -): ReaderScrollSnapshot? { - if (totalItemsCount <= 0 || firstVisibleItemSize <= 0) return null - - val itemSize = firstVisibleItemSize.coerceAtLeast(1) - val currentScrollOffset = firstVisibleItemMeasuredIndex.toFloat() + - (firstVisibleItemScrollOffset.toFloat() / itemSize.toFloat()) - val viewportHeightInItems = viewportHeightPx.toFloat() / itemSize.toFloat() - val maxScrollOffset = (totalItemsCount - 1).coerceAtLeast(0).toFloat() + viewportHeightInItems - - return ReaderScrollSnapshot( - scrollOffset = currentScrollOffset, - maxScrollOffset = maxScrollOffset, - viewportHeightInItems = viewportHeightInItems, - index = firstVisibleItemIndex, - offset = firstVisibleItemScrollOffset, - canScrollForward = canScrollForward, - firstVisibleItemSize = itemSize - ) -} - -internal fun flushReaderLifecycleProgress( - snapshot: ReaderScrollSnapshot?, - updateScrollPosition: (ReaderScrollSnapshot) -> Unit, - persistProgress: () -> Unit -) { - if (snapshot != null) { - updateScrollPosition(snapshot) - } - persistProgress() -} - @Composable private fun ReaderBottomNavigationBar( readerViewModel: ReaderViewModel, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt index 2cb8c33..9cc87c7 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt @@ -50,14 +50,9 @@ private fun readerContentType(element: ContentElement): String = when (element) is ContentElement.ImageGroup -> CONTENT_TYPE_IMAGE_GROUP } +// Element-key generator lives in viewmodel layer so non-UI code (progress restore) shares it. internal fun stableContentElementKey(pageUrl: String, index: Int, element: ContentElement): String { - return when (element) { - is ContentElement.Image -> "img:${element.url}" - is ContentElement.ImageGroup -> "group:${element.images.joinToString("|") { it.url }}" - is ContentElement.Text -> "txt:$pageUrl:$index:${element.content.take(64).hashCode()}" - is ContentElement.Placeholder -> "placeholder:$pageUrl:$index:${element.text}" - is ContentElement.PageContent -> "page:$pageUrl:$index" - } + return io.aatricks.easyreader.ui.viewmodel.stableContentElementKey(pageUrl, index, element) } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/util/ReaderRestore.kt b/app/src/main/java/io/aatricks/easyreader/ui/util/ReaderRestore.kt deleted file mode 100644 index 0ae247b..0000000 --- a/app/src/main/java/io/aatricks/easyreader/ui/util/ReaderRestore.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.aatricks.easyreader.ui.util - -import kotlin.math.roundToInt - -fun normalizeRestoreOffset(offsetPx: Int, itemSizePx: Int): Float? { - if (itemSizePx <= 0) return null - return (offsetPx.toFloat() / itemSizePx.toFloat()).coerceIn(0f, 1f) -} - -fun resolveRestoreOffset( - savedOffsetPx: Int, - savedOffsetFraction: Float?, - itemSizePx: Int -): Int { - if (itemSizePx > 0 && savedOffsetFraction != null) { - return (itemSizePx * savedOffsetFraction.coerceIn(0f, 1f)) - .roundToInt() - .coerceIn(0, itemSizePx) - } - - return savedOffsetPx.coerceAtLeast(0) -} 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 a25e922..8526279 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 @@ -3,14 +3,18 @@ package io.aatricks.easyreader.ui.viewmodel import android.util.Log import io.aatricks.easyreader.data.model.* import io.aatricks.easyreader.data.repository.LibraryRepository -import io.aatricks.easyreader.ui.util.normalizeRestoreOffset import io.aatricks.easyreader.util.FieldUpdate import kotlinx.coroutines.* import kotlinx.coroutines.flow.* /** - * Controller for managing reading progress persistence, restoration, and calculation. - * Extracted from ReaderViewModel to focus on progress logic and facilitate testing. + * Reading-position controller. Single source of truth for the unified position model: + * + * - `scrollPosition` / `scrollProgress`: % of the chapter (0..100) — UI display + last-resort restore. + * - `scrollElementKey`: stable per-item anchor (image URL, paragraph hash). Preferred over index. + * - `scrollIndex`: itemsIndexed fallback when the element key can't be located. + * - `scrollOffsetFraction`: intra-item fraction (0..1), or `FRACTION_UNKNOWN` (-1f) = unmeasured. + * - `firstVisibleItemSize`: stability witness; placeholder-sized items must not pollute the DB. */ class ReaderProgressController( private val libraryRepository: LibraryRepository, @@ -19,100 +23,134 @@ class ReaderProgressController( private val _progressState = MutableStateFlow(ReaderProgressState()) val progressState: StateFlow = _progressState.asStateFlow() - // Current library item ID being read var currentLibraryItemId: String? = null - // Suppress auto navigation when restoring a saved position until user interacts var suppressAutoNavUntilUserInteraction: Boolean = false var restoredScrollPercent: Float = 0f var hasUserInteractedSinceLoad: Boolean = false var restoredProgressSnapshot: ReaderProgressState? = null - // Track last raw scroll offset (pixels) to detect actual user gesture direction private var lastRawScrollOffset: Float = -1f private var lastReportedIndex: Int = -1 - private var lastReportedOffsetPx: Int = -1 + private var lastReportedFractionMillis: Int = -1 private var lastReportedProgress: Float = -1f - // Debounce progress updates to reduce jitter private var progressUpdateJob: Job? = null companion object { private const val TAG = "ReaderProgress" - private const val MIN_SCROLL_OFFSET_DELTA_PX = 8 + private const val MIN_SCROLL_FRACTION_DELTA_PERMILLE = 5 private const val MIN_SCROLL_PROGRESS_DELTA_PERCENT = 0.35f - private const val MIN_STABLE_MANHWA_ITEM_SIZE_PX = 96 + const val MIN_STABLE_ITEM_SIZE_PX = 96 } - fun syncProgressState( - scrollPosition: Float, - scrollProgress: Int, - scrollIndex: Int, - scrollOffset: Int, - scrollOffsetFraction: Float? = _progressState.value.scrollOffsetFraction, - firstVisibleItemSize: Int = _progressState.value.firstVisibleItemSize, - seekTrigger: Long = _progressState.value.seekTrigger, - targetScrollPosition: Float? = _progressState.value.targetScrollPosition - ) { - _progressState.value = ReaderProgressState( - scrollPosition = scrollPosition, - scrollProgress = scrollProgress, - scrollIndex = scrollIndex, - scrollOffset = scrollOffset, - scrollOffsetFraction = scrollOffsetFraction, - firstVisibleItemSize = firstVisibleItemSize, - seekTrigger = seekTrigger, - targetScrollPosition = targetScrollPosition - ) + fun syncProgressState(state: ReaderProgressState) { + _progressState.value = state } - fun calculateInitialScroll( + /** + * Compute the initial position when entering a chapter. Returns `ReaderProgressState` directly + * (the old `ScrollState` intermediary was a strict duplicate). Resolution order on restore: + * + * 1. If `libraryItem.progress == 0` → top. + * 2. Locate `lastReadElementKey` inside the current chapter's elements → use that index. + * 3. If `lastReadIndex` points to a valid element with a usable fraction → use it. + * 4. Otherwise derive a coarse index from `lastScrollPosition` (the percent). This handles + * legacy rows (missing key, FRACTION_UNKNOWN) and chapter-reparses where the element layout + * shifted. + */ + fun calculateInitialPosition( content: ChapterContent, libraryItem: LibraryItem?, fromBottom: Boolean, isExplicitNavigation: Boolean - ): ScrollState { - return if (libraryItem != null && !isExplicitNavigation) { - val shouldRestoreAtTop = libraryItem.progress == 0 - restoredScrollPercent = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition - suppressAutoNavUntilUserInteraction = true - hasUserInteractedSinceLoad = false - val scrollState = ScrollState( - index = if (shouldRestoreAtTop) 0 else libraryItem.lastReadIndex, - position = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition, - progress = if (shouldRestoreAtTop) 0 else libraryItem.progress, - offset = when { - shouldRestoreAtTop -> 0 - libraryItem.lastReadOffsetFraction != null -> 0 - else -> libraryItem.lastReadOffset - }, - offsetFraction = if (shouldRestoreAtTop) 0f else libraryItem.lastReadOffsetFraction, - targetPosition = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition - ) - restoredProgressSnapshot = ReaderProgressState( - scrollPosition = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition, - scrollProgress = if (shouldRestoreAtTop) 0 else libraryItem.progress, - scrollIndex = if (shouldRestoreAtTop) 0 else libraryItem.lastReadIndex, - scrollOffset = if (shouldRestoreAtTop) 0 else libraryItem.lastReadOffset, - scrollOffsetFraction = if (shouldRestoreAtTop) 0f else libraryItem.lastReadOffsetFraction, + ): ReaderProgressState { + suppressAutoNavUntilUserInteraction = true + hasUserInteractedSinceLoad = false + + if (libraryItem == null || isExplicitNavigation) { + restoredScrollPercent = if (fromBottom) 100f else 0f + val lastIndex = (content.paragraphs.size - 1).coerceAtLeast(0) + val state = ReaderProgressState( + scrollPosition = if (fromBottom) 100f else 0f, + scrollProgress = if (fromBottom) 100 else 0, + scrollIndex = if (fromBottom) lastIndex else 0, + scrollElementKey = "", + scrollOffsetFraction = if (fromBottom) 1f else 0f, firstVisibleItemSize = 0, seekTrigger = 0L, - targetScrollPosition = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition + targetScrollPosition = if (fromBottom) 100f else 0f ) - scrollState + restoredProgressSnapshot = state + return state + } + + val shouldRestoreAtTop = libraryItem.progress == 0 + restoredScrollPercent = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition + + val resolved = if (shouldRestoreAtTop) { + ResolvedPosition(index = 0, elementKey = "", fraction = 0f) } else { - restoredScrollPercent = if (fromBottom) 100f else 0f - suppressAutoNavUntilUserInteraction = true - hasUserInteractedSinceLoad = false - ScrollState( - index = if (fromBottom) (content.paragraphs.size - 1).coerceAtLeast(0) else 0, - position = if (fromBottom) 100f else 0f, - progress = if (fromBottom) 100 else 0, - offset = if (fromBottom) 10000000 else 0, - offsetFraction = if (fromBottom) 1f else 0f, - targetPosition = if (fromBottom) 100f else 0f - ).also { restoredProgressSnapshot = it.toProgressState() } + resolveRestoredIndex(content, libraryItem) + } + + val state = ReaderProgressState( + scrollPosition = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition, + scrollProgress = if (shouldRestoreAtTop) 0 else libraryItem.progress, + scrollIndex = resolved.index, + scrollElementKey = resolved.elementKey, + scrollOffsetFraction = resolved.fraction, + firstVisibleItemSize = 0, + seekTrigger = 0L, + targetScrollPosition = if (shouldRestoreAtTop) 0f else libraryItem.lastScrollPosition + ) + restoredProgressSnapshot = state + return state + } + + private fun resolveRestoredIndex(content: ChapterContent, libraryItem: LibraryItem): ResolvedPosition { + val totalItems = content.paragraphs.size + if (totalItems <= 0) { + return ResolvedPosition(index = 0, elementKey = "", fraction = 0f) + } + val lastItemIndex = totalItems - 1 + + // (2) Stable element-key anchor — survives reorderings, splits, item-count drift. + val savedKey = libraryItem.lastReadElementKey + if (savedKey.isNotEmpty()) { + content.paragraphs.forEachIndexed { idx, element -> + if (stableContentElementKey(content.url, idx, element) == savedKey) { + return ResolvedPosition( + index = idx, + elementKey = savedKey, + fraction = libraryItem.lastReadOffsetFraction.takeIf { it >= 0f } ?: 0f + ) + } + } + } + + // (3) Saved index with usable fraction. + val savedIndex = libraryItem.lastReadIndex.coerceIn(0, lastItemIndex) + val hasUsableFraction = libraryItem.lastReadOffsetFraction >= 0f + val hasSavedPosition = libraryItem.lastReadIndex > 0 || hasUsableFraction + if (hasSavedPosition) { + val refreshedKey = content.paragraphs.getOrNull(savedIndex) + ?.let { stableContentElementKey(content.url, savedIndex, it) } + ?: "" + return ResolvedPosition( + index = savedIndex, + elementKey = refreshedKey, + fraction = if (hasUsableFraction) libraryItem.lastReadOffsetFraction else 0f + ) } + + // (4) Percent fallback. Legacy rows with missing key/fraction land here. + val percent = libraryItem.lastScrollPosition.coerceIn(0f, 100f) / 100f + val derivedIndex = (percent * lastItemIndex).toInt().coerceIn(0, lastItemIndex) + val refreshedKey = content.paragraphs.getOrNull(derivedIndex) + ?.let { stableContentElementKey(content.url, derivedIndex, it) } + ?: "" + return ResolvedPosition(index = derivedIndex, elementKey = refreshedKey, fraction = 0f) } fun onUserInteraction( @@ -133,17 +171,12 @@ class ReaderProgressController( hasUserInteractedSinceLoad = true suppressAutoNavUntilUserInteraction = false restoredProgressSnapshot = null - - var nextUiTargetScrollPosition = uiTargetScrollPosition - var nextUiPendingRestoreOffsetFraction = uiPendingRestoreOffsetFraction - - if (uiTargetScrollPosition != null || uiPendingRestoreOffsetFraction != null) { - nextUiTargetScrollPosition = null - nextUiPendingRestoreOffsetFraction = null - } - + + val nextUiTargetScrollPosition = if (uiTargetScrollPosition != null) null else uiTargetScrollPosition + val nextUiPendingRestoreOffsetFraction = if (uiPendingRestoreOffsetFraction != null) null else uiPendingRestoreOffsetFraction + updateUiState(nextUiTargetScrollPosition, nextUiPendingRestoreOffsetFraction) - + if (progressState.targetScrollPosition != null) { _progressState.update { it.copy(targetScrollPosition = null) } } @@ -152,20 +185,20 @@ class ReaderProgressController( suspend fun saveCurrentProgress(content: ChapterContent?) { val prevItemId = currentLibraryItemId ?: return val prevContent = content ?: return - val progressSnapshot = currentPersistedSnapshot() + val snapshot = currentPersistedSnapshot() - if (isPlaceholderAtCurrentPosition(prevContent, progressSnapshot.scrollIndex)) return + if (!isSnapshotPersistable(prevContent, snapshot)) return runCatching { libraryRepository.updateProgressExplicit( itemId = prevItemId, currentChapter = "", - progress = FieldUpdate.Set(progressSnapshot.scrollProgress), + progress = FieldUpdate.Set(snapshot.scrollProgress), currentChapterUrl = FieldUpdate.Set(prevContent.url), - lastScrollProgress = FieldUpdate.Set(progressSnapshot.scrollPosition), - lastReadIndex = FieldUpdate.Set(progressSnapshot.scrollIndex), - lastReadOffset = FieldUpdate.Set(progressSnapshot.scrollOffset), - lastReadOffsetFraction = progressSnapshot.scrollOffsetFraction?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Clear + lastScrollProgress = FieldUpdate.Set(snapshot.scrollPosition), + lastReadIndex = FieldUpdate.Set(snapshot.scrollIndex), + lastReadElementKey = FieldUpdate.Set(snapshot.scrollElementKey), + lastReadOffsetFraction = FieldUpdate.Set(snapshot.scrollOffsetFraction) ) } } @@ -178,43 +211,51 @@ class ReaderProgressController( } } + /** + * Guard the DB write paths: refuse to persist measurements taken against placeholder-sized + * items, and refuse to persist an unknown fraction. Stops the placeholder-pollution pipeline + * that caused "saved progress but no anchor" rows under the old code. + */ + fun isSnapshotPersistable(content: ChapterContent?, snapshot: ReaderProgressState): Boolean { + if (content == null || content.paragraphs.isEmpty()) return false + if (snapshot.scrollOffsetFraction < 0f) return false + if (snapshot.firstVisibleItemSize in 1 until MIN_STABLE_ITEM_SIZE_PX) { + // Item measured but at placeholder size — fraction is meaningless. Drop write. + return false + } + return !isPlaceholderAtCurrentPosition(content, snapshot.scrollIndex) + } + fun updateScrollPosition( scrollOffset: Float, maxScrollOffset: Float, viewportHeight: Float, index: Int, - offset: Int, + offsetFraction: Float, + elementKey: String, content: ChapterContent?, canScrollForward: Boolean = true, firstVisibleItemSize: Int = 0 ) { - val deltaRaw = if (lastRawScrollOffset < 0f) 0f else scrollOffset - lastRawScrollOffset - // Note: isScrollingDown is not used here but kept for logic consistency if needed later - // val isScrollingDown = deltaRaw > 0f - val progress = when { !canScrollForward -> 100f - maxScrollOffset > viewportHeight -> ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn( - 0f, - 100f - ) - + maxScrollOffset > viewportHeight -> ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f) maxScrollOffset > 0 -> 100f else -> 0f } - val progressInt = progress.toInt() + val fractionPermille = (offsetFraction.coerceIn(0f, 1f) * 1000f).toInt() + val isMicroDelta = index == lastReportedIndex && - kotlin.math.abs(offset - lastReportedOffsetPx) < MIN_SCROLL_OFFSET_DELTA_PX && + kotlin.math.abs(fractionPermille - lastReportedFractionMillis) < MIN_SCROLL_FRACTION_DELTA_PERMILLE && kotlin.math.abs(progress - lastReportedProgress) < MIN_SCROLL_PROGRESS_DELTA_PERCENT if (isMicroDelta) { lastRawScrollOffset = scrollOffset return } - lastReportedIndex = index - lastReportedOffsetPx = offset + lastReportedFractionMillis = fractionPermille lastReportedProgress = progress if (suppressAutoNavUntilUserInteraction) { @@ -222,18 +263,20 @@ class ReaderProgressController( return } - val offsetFraction = normalizeRestoreOffset(offset, firstVisibleItemSize) + val isStable = firstVisibleItemSize >= MIN_STABLE_ITEM_SIZE_PX + val effectiveFraction = if (isStable) offsetFraction.coerceIn(0f, 1f) else FRACTION_UNKNOWN _progressState.value = _progressState.value.copy( scrollPosition = progress, scrollProgress = progressInt, scrollIndex = index, - scrollOffset = offset, - scrollOffsetFraction = offsetFraction, + scrollElementKey = if (isStable) elementKey else _progressState.value.scrollElementKey, + scrollOffsetFraction = effectiveFraction, firstVisibleItemSize = firstVisibleItemSize ) - if (shouldSkipPersistForUnstableManhwaSample(content, index, firstVisibleItemSize)) { + // Only schedule a DB write when the sample is stable enough to be meaningful. + if (!isStable) { lastRawScrollOffset = scrollOffset return } @@ -241,14 +284,13 @@ class ReaderProgressController( progressUpdateJob?.cancel() progressUpdateJob = scope.launch { delay(100) - if (progressInt >= 0) { updateReadingProgress( progress = progressInt, scrollPosition = progress, index = index, - offset = offset, - offsetFraction = offsetFraction, + elementKey = elementKey, + offsetFraction = effectiveFraction, content = content ) } @@ -260,7 +302,7 @@ class ReaderProgressController( progress: Int, scrollPosition: Float? = null, index: Int? = null, - offset: Int? = null, + elementKey: String? = null, offsetFraction: Float? = null, currentChapterUrl: String? = null, content: ChapterContent? = null @@ -271,20 +313,15 @@ class ReaderProgressController( val latest = currentPersistedSnapshot() val lastScroll = scrollPosition ?: latest.scrollPosition val lastIndex = index ?: latest.scrollIndex - val lastOffset = offset ?: latest.scrollOffset - val lastOffsetFraction = offsetFraction ?: latest.scrollOffsetFraction + val lastElementKey = elementKey ?: latest.scrollElementKey + val lastFraction = offsetFraction ?: latest.scrollOffsetFraction if (isPlaceholderAtCurrentPosition(content, lastIndex)) return@runCatching + if (lastFraction < 0f) return@runCatching - val currentElement = content?.paragraphs?.getOrNull(lastIndex) - val elementAnchor = when (currentElement) { - is ContentElement.Image -> currentElement.url - is ContentElement.ImageGroup -> currentElement.images.firstOrNull()?.url - else -> null - } Log.d( TAG, - "saveProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(resolvedChapterUrl)} index=$lastIndex offset=$lastOffset offsetFraction=$lastOffsetFraction firstVisibleItemSize=${latest.firstVisibleItemSize} anchor=${if (elementAnchor != null) "" else "null"}" + "saveProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(resolvedChapterUrl)} index=$lastIndex elementKey=${if (lastElementKey.isNotEmpty()) "" else ""} fraction=$lastFraction firstVisibleItemSize=${latest.firstVisibleItemSize}" ) libraryRepository.updateProgressExplicit( @@ -294,8 +331,8 @@ class ReaderProgressController( currentChapterUrl = FieldUpdate.Set(resolvedChapterUrl), lastScrollProgress = FieldUpdate.Set(lastScroll), lastReadIndex = FieldUpdate.Set(lastIndex), - lastReadOffset = FieldUpdate.Set(lastOffset), - lastReadOffsetFraction = lastOffsetFraction?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Clear + lastReadElementKey = FieldUpdate.Set(lastElementKey), + lastReadOffsetFraction = FieldUpdate.Set(lastFraction) ) } } @@ -304,8 +341,8 @@ class ReaderProgressController( val lastIndex = index ?: _progressState.value.scrollIndex val paragraphs = content?.paragraphs ?: return false val currentItem = paragraphs.getOrNull(lastIndex) - return currentItem is ContentElement.Placeholder || - (currentItem is ContentElement.Text && currentItem.content.startsWith("Loading page")) + return currentItem is ContentElement.Placeholder || + (currentItem is ContentElement.Text && currentItem.content.startsWith("Loading page")) } fun resetState() { @@ -315,7 +352,7 @@ class ReaderProgressController( restoredProgressSnapshot = null lastRawScrollOffset = -1f lastReportedIndex = -1 - lastReportedOffsetPx = -1 + lastReportedFractionMillis = -1 lastReportedProgress = -1f progressUpdateJob?.cancel() } @@ -323,62 +360,36 @@ class ReaderProgressController( fun cancelProgressUpdate() { progressUpdateJob?.cancel() } - - private fun shouldSkipPersistForUnstableManhwaSample( - content: ChapterContent?, - index: Int, - firstVisibleItemSize: Int - ): Boolean { - if (content == null || firstVisibleItemSize >= MIN_STABLE_MANHWA_ITEM_SIZE_PX) return false - - val isLongStrip = isLongStripContent(content) - if (!isLongStrip) return false - - return when (content.paragraphs.getOrNull(index)) { - is ContentElement.Image, is ContentElement.ImageGroup -> true - else -> false - } - } - - private fun isLongStripContent(content: ChapterContent): Boolean { - val isManga = content.url.contains("manga", ignoreCase = true) && - !content.url.contains("manhwa", ignoreCase = true) && - !content.url.contains("webtoon", ignoreCase = true) - if (isManga) return false - - return content.url.contains("manhwa", ignoreCase = true) || - content.url.contains("webtoon", ignoreCase = true) || - (content.getImageCount() > content.getTextCount() && content.getImageCount() > 2) - } } data class ReaderProgressState( val scrollPosition: Float = 0f, val scrollProgress: Int = 0, val scrollIndex: Int = 0, - val scrollOffset: Int = 0, - val scrollOffsetFraction: Float? = null, + val scrollElementKey: String = "", + val scrollOffsetFraction: Float = FRACTION_UNKNOWN, val firstVisibleItemSize: Int = 0, val seekTrigger: Long = 0L, val targetScrollPosition: Float? = null ) -data class ScrollState( +private data class ResolvedPosition( val index: Int, - val position: Float, - val progress: Int, - val offset: Int, - val offsetFraction: Float?, - val targetPosition: Float? = null + val elementKey: String, + val fraction: Float ) -internal fun ScrollState.toProgressState(): ReaderProgressState = ReaderProgressState( - scrollPosition = position, - scrollProgress = progress, - scrollIndex = index, - scrollOffset = offset, - scrollOffsetFraction = offsetFraction, - firstVisibleItemSize = 0, - seekTrigger = 0L, - targetScrollPosition = targetPosition -) +/** + * Lifted from the Compose renderer so non-UI code can resolve element anchors. Keep this + * deterministic and pure — both the writer (during scroll) and the reader (during restore) must + * agree on the same key for the same logical element. + */ +internal fun stableContentElementKey(pageUrl: String, index: Int, element: ContentElement): String { + return when (element) { + is ContentElement.Image -> "img:${element.url}" + is ContentElement.ImageGroup -> "group:${element.images.joinToString("|") { it.url }}" + is ContentElement.Text -> "txt:$pageUrl:$index:${element.content.take(64).hashCode()}" + is ContentElement.Placeholder -> "placeholder:$pageUrl:$index:${element.text}" + is ContentElement.PageContent -> "page:$pageUrl:$index" + } +} 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 d8e8d0f..2d8e390 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 @@ -12,7 +12,6 @@ import io.aatricks.easyreader.data.repository.ExploreRepository import io.aatricks.easyreader.data.repository.LibraryRepository import io.aatricks.easyreader.ui.theme.AccentTheme import io.aatricks.easyreader.util.normalizeChapterList -import io.aatricks.easyreader.ui.util.normalizeRestoreOffset import io.aatricks.easyreader.util.TextUtils import io.aatricks.easyreader.util.UrlSecurity import io.aatricks.easyreader.util.FieldUpdate @@ -63,9 +62,9 @@ class ReaderViewModel @Inject constructor( get() = progressController.restoredScrollPercent set(value) { progressController.restoredScrollPercent = value } - private var hasUserInteractedSinceLoad: Boolean + var hasUserInteractedSinceLoad: Boolean get() = progressController.hasUserInteractedSinceLoad - set(value) { progressController.hasUserInteractedSinceLoad = value } + private set(value) { progressController.hasUserInteractedSinceLoad = value } private var restoredProgressSnapshot: ReaderProgressState? get() = progressController.restoredProgressSnapshot @@ -169,8 +168,9 @@ class ReaderViewModel @Inject constructor( val scrollPosition: Float = 0f, val scrollProgress: Int = 0, val scrollIndex: Int = 0, - val scrollOffset: Int = 0, - val pendingRestoreOffsetFraction: Float? = null, + val restoreElementKey: String = "", + // Sentinel FRACTION_UNKNOWN (-1f) = no restore pending; 0..1 = pending intra-item fraction. + val restoreOffsetFraction: Float = io.aatricks.easyreader.data.model.FRACTION_UNKNOWN, val isScrollingDown: Boolean = true, val hasReachedQuarterScreen: Boolean = false, val canNavigateNext: Boolean = false, @@ -239,34 +239,16 @@ class ReaderViewModel @Inject constructor( scrollPosition = scrollPosition, scrollProgress = scrollProgress, scrollIndex = scrollIndex, - scrollOffset = scrollOffset, - scrollOffsetFraction = pendingRestoreOffsetFraction, + scrollElementKey = restoreElementKey, + scrollOffsetFraction = restoreOffsetFraction, firstVisibleItemSize = 0, seekTrigger = seekTrigger, targetScrollPosition = targetScrollPosition ) } - private fun syncProgressState( - scrollPosition: Float, - scrollProgress: Int, - scrollIndex: Int, - scrollOffset: Int, - scrollOffsetFraction: Float? = progressController.progressState.value.scrollOffsetFraction, - firstVisibleItemSize: Int = progressController.progressState.value.firstVisibleItemSize, - seekTrigger: Long = progressController.progressState.value.seekTrigger, - targetScrollPosition: Float? = progressController.progressState.value.targetScrollPosition - ) { - progressController.syncProgressState( - scrollPosition = scrollPosition, - scrollProgress = scrollProgress, - scrollIndex = scrollIndex, - scrollOffset = scrollOffset, - scrollOffsetFraction = scrollOffsetFraction, - firstVisibleItemSize = firstVisibleItemSize, - seekTrigger = seekTrigger, - targetScrollPosition = targetScrollPosition - ) + private fun syncProgressState(state: ReaderProgressState) { + progressController.syncProgressState(state) } fun requestOpenFile(uri: String): Unit { @@ -476,8 +458,8 @@ class ReaderViewModel @Inject constructor( 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")) + return currentItem is ContentElement.Placeholder || + (currentItem is ContentElement.Text && currentItem.content.startsWith("Loading page")) } private suspend fun saveCurrentProgress(): Unit { @@ -526,7 +508,7 @@ class ReaderViewModel @Inject constructor( content )) - val initialScroll = progressController.calculateInitialScroll(content, libraryItem, fromBottom, isExplicitNavigation) + val initialPosition = progressController.calculateInitialPosition(content, libraryItem, fromBottom, isExplicitNavigation) var currentFullList = _uiState.value.fullChapterList // If we switched novels, discard the old list @@ -559,13 +541,13 @@ class ReaderViewModel @Inject constructor( lastIsExplicitNavigation = false, canNavigateNext = content.hasNextChapter(), canNavigatePrevious = content.hasPreviousChapter(), - scrollPosition = initialScroll.position, - scrollProgress = initialScroll.progress, - scrollIndex = initialScroll.index, - scrollOffset = initialScroll.offset, - pendingRestoreOffsetFraction = initialScroll.offsetFraction, - targetScrollPosition = initialScroll.targetPosition, - hasReachedQuarterScreen = fromBottom || initialScroll.progress >= 25, + scrollPosition = initialPosition.scrollPosition, + scrollProgress = initialPosition.scrollProgress, + scrollIndex = initialPosition.scrollIndex, + restoreElementKey = initialPosition.scrollElementKey, + restoreOffsetFraction = initialPosition.scrollOffsetFraction, + targetScrollPosition = initialPosition.targetScrollPosition, + hasReachedQuarterScreen = fromBottom || initialPosition.scrollProgress >= 25, novelName = novelName, chapterTitle = chapterTitle, baseTitle = baseTitle, @@ -575,14 +557,7 @@ class ReaderViewModel @Inject constructor( fullChapterList = currentFullList ) } - syncProgressState( - scrollPosition = initialScroll.position, - scrollProgress = initialScroll.progress, - scrollIndex = initialScroll.index, - scrollOffset = initialScroll.offset, - scrollOffsetFraction = initialScroll.offsetFraction, - targetScrollPosition = initialScroll.targetPosition - ) + syncProgressState(initialPosition) updateNavigationUrls() maybeWarmNextChapter(_uiState.value.content?.nextChapterUrl) @@ -838,7 +813,7 @@ class ReaderViewModel @Inject constructor( libraryItem?.currentChapter ?: "" } - val initialScroll = progressController.calculateInitialScroll(content, libraryItem, fromBottom, isExplicitNavigation) + val initialPosition = progressController.calculateInitialPosition(content, libraryItem, fromBottom, isExplicitNavigation) closeContent(_uiState.value.content) updateState { @@ -849,12 +824,13 @@ class ReaderViewModel @Inject constructor( error = null, canNavigateNext = content.hasNextChapter(), canNavigatePrevious = content.hasPreviousChapter(), - scrollPosition = initialScroll.position, - scrollProgress = initialScroll.progress, - scrollIndex = initialScroll.index, - scrollOffset = initialScroll.offset, - targetScrollPosition = initialScroll.targetPosition, - hasReachedQuarterScreen = fromBottom || initialScroll.progress >= 25, + scrollPosition = initialPosition.scrollPosition, + scrollProgress = initialPosition.scrollProgress, + scrollIndex = initialPosition.scrollIndex, + restoreElementKey = initialPosition.scrollElementKey, + restoreOffsetFraction = initialPosition.scrollOffsetFraction, + targetScrollPosition = initialPosition.targetScrollPosition, + hasReachedQuarterScreen = fromBottom || initialPosition.scrollProgress >= 25, novelName = novelName, chapterTitle = chapterTitle, baseTitle = baseTitle, @@ -864,13 +840,7 @@ class ReaderViewModel @Inject constructor( fullChapterList = tocChapterList ) } - syncProgressState( - scrollPosition = initialScroll.position, - scrollProgress = initialScroll.progress, - scrollIndex = initialScroll.index, - scrollOffset = initialScroll.offset, - targetScrollPosition = initialScroll.targetPosition - ) + syncProgressState(initialPosition) preferencesManager.batchUpdateLastRead(content.url, effectiveLibraryItemId) @@ -879,7 +849,7 @@ class ReaderViewModel @Inject constructor( libraryRepository.saveProgressAsync( itemId = id, currentChapter = chapterTitle, - progress = initialScroll.progress, + progress = initialPosition.scrollProgress, currentChapterUrl = content.url ) } @@ -919,17 +889,26 @@ class ReaderViewModel @Inject constructor( } fun onUserInteraction(): Unit { + val pendingFraction = _uiState.value.restoreOffsetFraction + .takeIf { it >= 0f } progressController.onUserInteraction( uiTargetScrollPosition = _uiState.value.targetScrollPosition, - uiPendingRestoreOffsetFraction = _uiState.value.pendingRestoreOffsetFraction, + uiPendingRestoreOffsetFraction = pendingFraction, updateUiState = { targetScrollPosition, pendingRestoreOffsetFraction -> - updateState { it.copy(targetScrollPosition = targetScrollPosition, pendingRestoreOffsetFraction = pendingRestoreOffsetFraction) } + updateState { + it.copy( + targetScrollPosition = targetScrollPosition, + restoreOffsetFraction = pendingRestoreOffsetFraction + ?: io.aatricks.easyreader.data.model.FRACTION_UNKNOWN + ) + } } ) } suspend fun persistLifecycleProgress(): Unit { val currentChapterUrl = _uiState.value.content?.url ?: return + val content = _uiState.value.content ?: return progressController.cancelProgressUpdate() val latest = currentPersistedSnapshot() val shouldSnapToTop = !hasUserInteractedSinceLoad && latest.scrollProgress == 0 @@ -948,16 +927,24 @@ class ReaderViewModel @Inject constructor( } } + if (!shouldSnapToTop && !progressController.isSnapshotPersistable(content, latest)) { + Log.d( + TAG, + "persistLifecycleProgress skip unstable url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} firstVisibleItemSize=${latest.firstVisibleItemSize} fraction=${latest.scrollOffsetFraction}" + ) + return + } + Log.d( TAG, - "persistLifecycleProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} index=${latest.scrollIndex} offset=${latest.scrollOffset} offsetFraction=${latest.scrollOffsetFraction} firstVisibleItemSize=${latest.firstVisibleItemSize}" + "persistLifecycleProgress url=${io.aatricks.easyreader.util.UrlSanitizer.sanitize(currentChapterUrl)} index=${latest.scrollIndex} fraction=${latest.scrollOffsetFraction} firstVisibleItemSize=${latest.firstVisibleItemSize}" ) updateReadingProgress( progress = if (shouldSnapToTop) 0 else latest.scrollProgress, scrollPosition = if (shouldSnapToTop) 0f else latest.scrollPosition, index = if (shouldSnapToTop) 0 else latest.scrollIndex, - offset = if (shouldSnapToTop) 0 else latest.scrollOffset, + elementKey = if (shouldSnapToTop) "" else latest.scrollElementKey, offsetFraction = if (shouldSnapToTop) 0f else latest.scrollOffsetFraction, currentChapterUrl = currentChapterUrl ) @@ -972,7 +959,8 @@ class ReaderViewModel @Inject constructor( maxScrollOffset: Float, viewportHeight: Float, index: Int, - offset: Int, + offsetFraction: Float, + elementKey: String, canScrollForward: Boolean = true, firstVisibleItemSize: Int = 0 ): Unit { @@ -981,7 +969,8 @@ class ReaderViewModel @Inject constructor( maxScrollOffset = maxScrollOffset, viewportHeight = viewportHeight, index = index, - offset = offset, + offsetFraction = offsetFraction, + elementKey = elementKey, content = _uiState.value.content, canScrollForward = canScrollForward, firstVisibleItemSize = firstVisibleItemSize @@ -993,7 +982,7 @@ class ReaderViewModel @Inject constructor( progress: Int, scrollPosition: Float? = null, index: Int? = null, - offset: Int? = null, + elementKey: String? = null, offsetFraction: Float? = null, currentChapterUrl: String? = null ): Unit { @@ -1001,7 +990,7 @@ class ReaderViewModel @Inject constructor( progress = progress, scrollPosition = scrollPosition, index = index, - offset = offset, + elementKey = elementKey, offsetFraction = offsetFraction, currentChapterUrl = currentChapterUrl, content = _uiState.value.content @@ -1086,10 +1075,10 @@ class ReaderViewModel @Inject constructor( fun saveScrollPosition(position: Float): Unit { progressController.syncProgressState( - scrollPosition = position, - scrollProgress = position.toInt(), - scrollIndex = progressController.progressState.value.scrollIndex, - scrollOffset = progressController.progressState.value.scrollOffset + progressController.progressState.value.copy( + scrollPosition = position, + scrollProgress = position.toInt() + ) ) } @@ -1099,29 +1088,38 @@ class ReaderViewModel @Inject constructor( fun seekToProgress(progress: Float): Unit { val targetPercent = progress.coerceIn(0f, 100f) - val totalItems = _uiState.value.content?.paragraphs?.size ?: 0 + val content = _uiState.value.content + val totalItems = content?.paragraphs?.size ?: 0 val preciseItemIndex = (targetPercent / 100f) * (totalItems - 1).coerceAtLeast(0) val roughIndex = preciseItemIndex.toInt().coerceIn(0, (totalItems - 1).coerceAtLeast(0)) - val offset = if (targetPercent == 100f) 10000000 else 0 + val targetFraction = if (targetPercent == 100f) 1f else 0f + val targetElementKey = content?.paragraphs?.getOrNull(roughIndex) + ?.let { stableContentElementKey(content.url, roughIndex, it) } + ?: "" updateState { it.copy( scrollPosition = targetPercent, scrollProgress = targetPercent.toInt(), scrollIndex = roughIndex, - scrollOffset = offset, + restoreElementKey = targetElementKey, + restoreOffsetFraction = targetFraction, seekTrigger = System.currentTimeMillis(), targetScrollPosition = if (targetPercent == 100f) 100f else null ) } syncProgressState( - scrollPosition = targetPercent, - scrollProgress = targetPercent.toInt(), - scrollIndex = roughIndex, - scrollOffset = offset, - scrollOffsetFraction = if (targetPercent == 100f) 1f else 0f, - targetScrollPosition = if (targetPercent == 100f) 100f else null + ReaderProgressState( + scrollPosition = targetPercent, + scrollProgress = targetPercent.toInt(), + scrollIndex = roughIndex, + scrollElementKey = targetElementKey, + scrollOffsetFraction = targetFraction, + firstVisibleItemSize = progressController.progressState.value.firstVisibleItemSize, + seekTrigger = System.currentTimeMillis(), + targetScrollPosition = if (targetPercent == 100f) 100f else null + ) ) viewModelScope.launch { @@ -1129,8 +1127,8 @@ class ReaderViewModel @Inject constructor( progress = targetPercent.toInt(), scrollPosition = targetPercent, index = roughIndex, - offset = offset, - offsetFraction = if (targetPercent == 100f) 1f else 0f + elementKey = targetElementKey, + offsetFraction = targetFraction ) } } @@ -1139,20 +1137,20 @@ class ReaderViewModel @Inject constructor( val content = _uiState.value.content val progressToPersist = currentPersistedSnapshot() val chapterUrl = content?.url - - chapterUrl?.let { url -> + + if (chapterUrl != null && progressController.isSnapshotPersistable(content, progressToPersist)) { libraryRepository.saveProgressExplicitAsync( itemId = currentLibraryItemId ?: "", currentChapter = "", progress = FieldUpdate.Set(progressToPersist.scrollProgress), - currentChapterUrl = FieldUpdate.Set(url), + currentChapterUrl = FieldUpdate.Set(chapterUrl), lastScrollProgress = FieldUpdate.Set(progressToPersist.scrollPosition), lastReadIndex = FieldUpdate.Set(progressToPersist.scrollIndex), - lastReadOffset = FieldUpdate.Set(progressToPersist.scrollOffset), - lastReadOffsetFraction = progressToPersist.scrollOffsetFraction?.let { FieldUpdate.Set(it) } ?: FieldUpdate.Clear + lastReadElementKey = FieldUpdate.Set(progressToPersist.scrollElementKey), + lastReadOffsetFraction = FieldUpdate.Set(progressToPersist.scrollOffsetFraction) ) } - + super.onCleared() closeContent(content) } diff --git a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt index 882e71a..d058d9d 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt @@ -10,13 +10,13 @@ import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry import io.aatricks.easyreader.data.model.ContentType +import io.aatricks.easyreader.data.model.FRACTION_UNKNOWN import io.aatricks.easyreader.data.model.ReadingMode import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -49,7 +49,7 @@ class AppDatabaseMigrationTest { createVersion1Database(dbName) migrationTestHelper.runMigrationsAndValidate(dbName, CURRENT_VERSION, true, *ALL_MIGRATIONS).close() - assertMigratedCurrentData(dbName, expectsLastReadOffsetFraction = false) + assertMigratedCurrentData(dbName, hadFraction = false) } @Test @@ -58,7 +58,7 @@ class AppDatabaseMigrationTest { createVersion2Database(dbName) migrationTestHelper.runMigrationsAndValidate(dbName, CURRENT_VERSION, true, *ALL_MIGRATIONS).close() - assertMigratedCurrentData(dbName, expectsLastReadOffsetFraction = false) + assertMigratedCurrentData(dbName, hadFraction = false) } @Test @@ -67,7 +67,7 @@ class AppDatabaseMigrationTest { createVersion3Database(dbName) migrationTestHelper.runMigrationsAndValidate(dbName, CURRENT_VERSION, true, *ALL_MIGRATIONS).close() - assertMigratedCurrentData(dbName, expectsLastReadOffsetFraction = true) + assertMigratedCurrentData(dbName, hadFraction = true) } @Test @@ -76,7 +76,134 @@ class AppDatabaseMigrationTest { createVersion4Database(dbName) migrationTestHelper.runMigrationsAndValidate(dbName, CURRENT_VERSION, true, *ALL_MIGRATIONS).close() - assertMigratedCurrentData(dbName, expectsLastReadOffsetFraction = true) + assertMigratedCurrentData(dbName, hadFraction = true) + } + + @Test + fun migrate7To8_addsElementKeyAndNormalizesFraction() { + val dbName = migrationDbName("7-to-8") + createDatabaseAtVersion( + dbName = dbName, + version = 7, + createTableSql = """ + CREATE TABLE library_items ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + timestamp INTEGER NOT NULL, + progress INTEGER NOT NULL, + isCurrentlyReading INTEGER NOT NULL, + currentChapter TEXT NOT NULL, + currentChapterUrl TEXT NOT NULL, + totalChapters INTEGER NOT NULL, + contentType TEXT NOT NULL, + dateAdded INTEGER NOT NULL, + lastRead INTEGER NOT NULL, + isDownloading INTEGER NOT NULL, + lastScrollPosition REAL NOT NULL, + lastReadIndex INTEGER NOT NULL, + lastReadOffset INTEGER NOT NULL, + lastReadOffsetFraction REAL, + hasUpdates INTEGER NOT NULL, + chapterSummaries TEXT NOT NULL, + baseTitle TEXT NOT NULL, + readingMode TEXT NOT NULL, + baseNovelUrl TEXT NOT NULL, + sourceName TEXT NOT NULL, + isDownloaded INTEGER NOT NULL DEFAULT 0, + downloadedAt INTEGER + ) + """.trimIndent(), + indexSqls = CURRENT_INDEX_SQL + listOf( + """ + CREATE TABLE chapter_image_state ( + chapterUrl TEXT NOT NULL, + imageUrl TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + lastAttemptMs INTEGER NOT NULL DEFAULT 0, + httpStatusCode INTEGER, + PRIMARY KEY(chapterUrl, imageUrl) + ) + """.trimIndent(), + "CREATE INDEX index_chapter_image_state_chapterUrl ON chapter_image_state (chapterUrl)", + "CREATE INDEX index_chapter_image_state_status ON chapter_image_state (status)" + ), + insertSqls = listOf( + version7ItemWithFractionInsertSql(), + version7ItemNullFractionInsertSql() + ) + ) + + migrationTestHelper.runMigrationsAndValidate( + dbName, + 8, + true, + AppDatabase.MIGRATION_7_8 + ).use { database -> + assertTrue("lastReadElementKey column must be present", hasColumn(database, "lastReadElementKey")) + assertFalse("lastReadOffset column must be dropped", hasColumn(database, "lastReadOffset")) + + database.query(SimpleSQLiteQuery("SELECT lastReadOffsetFraction, lastReadElementKey FROM library_items WHERE id = 'item-with-fraction'")) + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0.25f, cursor.getFloat(0), 0.0001f) + assertEquals("", cursor.getString(1)) + } + database.query(SimpleSQLiteQuery("SELECT lastReadOffsetFraction, lastReadElementKey FROM library_items WHERE id = 'item-legacy-null-fraction'")) + .use { cursor -> + assertTrue(cursor.moveToFirst()) + // Sentinel: null fraction → -1. + assertEquals(-1f, cursor.getFloat(0), 0.0001f) + assertEquals("", cursor.getString(1)) + } + } + } + + private suspend fun assertMigratedCurrentData(dbName: String, hadFraction: Boolean) { + val database = Room.databaseBuilder(context, AppDatabase::class.java, dbName) + .allowMainThreadQueries() + .addMigrations(*ALL_MIGRATIONS) + .build() + try { + val primary = database.libraryDao().getItemById("item-primary") + assertNotNull(primary) + assertEquals("My Novel", primary?.title) + assertEquals("https://example.com/novel", primary?.url) + assertEquals(75, primary?.progress) + assertEquals(true, primary?.isCurrentlyReading) + assertEquals("Chapter 12", primary?.currentChapter) + assertEquals("https://example.com/novel/chapter-12", primary?.currentChapterUrl) + assertEquals(12, primary?.totalChapters) + assertEquals(ContentType.WEB, primary?.contentType) + assertEquals(12345L, primary?.dateAdded) + assertEquals(23456L, primary?.lastRead) + assertEquals(1, primary?.lastReadIndex) + assertEquals("", primary?.lastReadElementKey) + if (hadFraction) { + assertEquals(0.25f, primary?.lastReadOffsetFraction) + } else { + assertEquals(FRACTION_UNKNOWN, primary?.lastReadOffsetFraction) + } + assertEquals(true, primary?.hasUpdates) + assertEquals("Base Title", primary?.baseTitle) + assertEquals(ReadingMode.PAGED, primary?.readingMode) + assertEquals("https://example.com/base", primary?.baseNovelUrl) + assertEquals("SourceName", primary?.sourceName) + + val nullable = database.libraryDao().getItemById("item-legacy-nullable") + assertNotNull(nullable) + assertEquals("https://example.com/novel-nullable", nullable?.url) + assertEquals("https://example.com/novel-nullable/chapter-1", nullable?.currentChapterUrl) + assertEquals(ReadingMode.VERTICAL, nullable?.readingMode) + assertEquals(FRACTION_UNKNOWN, nullable?.lastReadOffsetFraction) + assertEquals("", nullable?.lastReadElementKey) + + assertFalse(hasColumn(database.openHelper.readableDatabase, "isSelected")) + assertFalse(hasColumn(database.openHelper.readableDatabase, "lastReadOffset")) + } finally { + database.close() + } } @Test @@ -183,7 +310,6 @@ class AppDatabaseMigrationTest { assertTrue("chapter_image_state table must exist", hasTable(database, "chapter_image_state")) assertIndexExists(database, "index_chapter_image_state_chapterUrl") assertIndexExists(database, "index_chapter_image_state_status") - // Insert a row and confirm composite primary key behavior. database.execSQL( "INSERT INTO chapter_image_state (chapterUrl, imageUrl, status, attempts, lastAttemptMs, httpStatusCode) " + "VALUES ('c1', 'i1', 'PERMANENT_FAILURE', 1, 12345, 404)" @@ -403,50 +529,6 @@ class AppDatabaseMigrationTest { helper.close() } - private suspend fun assertMigratedCurrentData(dbName: String, expectsLastReadOffsetFraction: Boolean) { - val database = Room.databaseBuilder(context, AppDatabase::class.java, dbName) - .allowMainThreadQueries() - .addMigrations(*ALL_MIGRATIONS) - .build() - try { - val primary = database.libraryDao().getItemById("item-primary") - assertNotNull(primary) - assertEquals("My Novel", primary?.title) - assertEquals("https://example.com/novel", primary?.url) - assertEquals(75, primary?.progress) - assertEquals(true, primary?.isCurrentlyReading) - assertEquals("Chapter 12", primary?.currentChapter) - assertEquals("https://example.com/novel/chapter-12", primary?.currentChapterUrl) - assertEquals(12, primary?.totalChapters) - assertEquals(ContentType.WEB, primary?.contentType) - assertEquals(12345L, primary?.dateAdded) - assertEquals(23456L, primary?.lastRead) - assertEquals(1, primary?.lastReadIndex) - assertEquals(9, primary?.lastReadOffset) - if (expectsLastReadOffsetFraction) { - assertEquals(0.25f, primary?.lastReadOffsetFraction) - } else { - assertNull(primary?.lastReadOffsetFraction) - } - assertEquals(true, primary?.hasUpdates) - assertEquals("Base Title", primary?.baseTitle) - assertEquals(ReadingMode.PAGED, primary?.readingMode) - assertEquals("https://example.com/base", primary?.baseNovelUrl) - assertEquals("SourceName", primary?.sourceName) - - val nullable = database.libraryDao().getItemById("item-legacy-nullable") - assertNotNull(nullable) - assertEquals("https://example.com/novel-nullable", nullable?.url) - assertEquals("https://example.com/novel-nullable/chapter-1", nullable?.currentChapterUrl) - assertEquals(ReadingMode.VERTICAL, nullable?.readingMode) - assertNull(nullable?.lastReadOffsetFraction) - - assertFalse(hasColumn(database.openHelper.readableDatabase, "isSelected")) - } finally { - database.close() - } - } - private fun hasColumn(database: SupportSQLiteDatabase, columnName: String): Boolean { database.query(SimpleSQLiteQuery("PRAGMA table_info(library_items)")).use { cursor -> val nameIndex = cursor.getColumnIndex("name") @@ -553,49 +635,76 @@ class AppDatabaseMigrationTest { } } - private fun version1StandardItemInsertSql(): String { - return """ - INSERT INTO library_items ( - id, title, url, timestamp, progress, isCurrentlyReading, isSelected, - currentChapter, currentChapterUrl, totalChapters, contentType, - dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, - lastReadOffset, hasUpdates, chapterSummaries, baseTitle, readingMode, - baseNovelUrl, sourceName, type - ) VALUES ( - 'item-primary', 'My Novel', 'https://example.com/novel', 111, 75, 1, 1, - 'Chapter 12', 'https://example.com/novel/chapter-12', 12, 'WEB', - 12345, 23456, 0, 0.75, 1, 9, 1, - '{}', 'Base Title', 'PAGED', 'https://example.com/base', 'SourceName', 'novel' - ) - """.trimIndent() - } - - private fun version1LegacyNullableItemInsertSql(): String { - return """ - INSERT INTO library_items ( - id, title, url, timestamp, progress, isCurrentlyReading, isSelected, - currentChapter, currentChapterUrl, totalChapters, contentType, - dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, - lastReadOffset, hasUpdates, chapterSummaries, baseTitle, readingMode, - baseNovelUrl, sourceName, type - ) VALUES ( - 'item-legacy-nullable', 'Nullable Legacy', 'https://example.com/novel-nullable', 222, 5, 0, 0, - 'Chapter 1', 'https://example.com/novel-nullable/chapter-1', 30, 'WEB', - 33333, 44444, 0, 0.0, 0, 0, 0, - '{}', 'Legacy Base', 'VERTICAL', 'https://example.com/legacy', 'LegacySource', 'novel' - ) - """.trimIndent() - } + private fun version7ItemWithFractionInsertSql(): String = """ + INSERT INTO library_items ( + id, title, url, timestamp, progress, isCurrentlyReading, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, + lastReadOffset, lastReadOffsetFraction, hasUpdates, chapterSummaries, + baseTitle, readingMode, baseNovelUrl, sourceName, isDownloaded, downloadedAt + ) VALUES ( + 'item-with-fraction', 'Test', 'https://example.com/v7-fraction', 100, 50, 0, + 'Chapter 5', 'https://example.com/v7-fraction', 5, 'WEB', + 10, 20, 0, 50.0, 4, 100, 0.25, 0, '{}', + 'TestBase', 'VERTICAL', 'https://example.com/v7-fraction-base', 'src', 0, NULL + ) + """.trimIndent() + + private fun version7ItemNullFractionInsertSql(): String = """ + INSERT INTO library_items ( + id, title, url, timestamp, progress, isCurrentlyReading, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, + lastReadOffset, lastReadOffsetFraction, hasUpdates, chapterSummaries, + baseTitle, readingMode, baseNovelUrl, sourceName, isDownloaded, downloadedAt + ) VALUES ( + 'item-legacy-null-fraction', 'Legacy', 'https://example.com/v7-null', 100, 50, 0, + 'Chapter 5', 'https://example.com/v7-null', 5, 'WEB', + 10, 20, 0, 50.0, 4, 100, NULL, 0, '{}', + 'TestBase', 'VERTICAL', 'https://example.com/v7-null-base', 'src', 0, NULL + ) + """.trimIndent() + + private fun version1StandardItemInsertSql(): String = """ + INSERT INTO library_items ( + id, title, url, timestamp, progress, isCurrentlyReading, isSelected, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, + lastReadOffset, hasUpdates, chapterSummaries, baseTitle, readingMode, + baseNovelUrl, sourceName, type + ) VALUES ( + 'item-primary', 'My Novel', 'https://example.com/novel', 111, 75, 1, 1, + 'Chapter 12', 'https://example.com/novel/chapter-12', 12, 'WEB', + 12345, 23456, 0, 0.75, 1, 9, 1, + '{}', 'Base Title', 'PAGED', 'https://example.com/base', 'SourceName', 'novel' + ) + """.trimIndent() + + private fun version1LegacyNullableItemInsertSql(): String = """ + INSERT INTO library_items ( + id, title, url, timestamp, progress, isCurrentlyReading, isSelected, + currentChapter, currentChapterUrl, totalChapters, contentType, + dateAdded, lastRead, isDownloading, lastScrollPosition, lastReadIndex, + lastReadOffset, hasUpdates, chapterSummaries, baseTitle, readingMode, + baseNovelUrl, sourceName, type + ) VALUES ( + 'item-legacy-nullable', 'Nullable Legacy', 'https://example.com/novel-nullable', 222, 5, 0, 0, + 'Chapter 1', 'https://example.com/novel-nullable/chapter-1', 30, 'WEB', + 33333, 44444, 0, 0.0, 0, 0, 0, + '{}', 'Legacy Base', 'VERTICAL', 'https://example.com/legacy', 'LegacySource', 'novel' + ) + """.trimIndent() companion object { - private const val CURRENT_VERSION = 7 + private const val CURRENT_VERSION = 8 private val ALL_MIGRATIONS = arrayOf( AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, - AppDatabase.MIGRATION_6_7 + AppDatabase.MIGRATION_6_7, + AppDatabase.MIGRATION_7_8 ) private val CURRENT_INDEX_SQL = listOf( "CREATE UNIQUE INDEX index_library_items_url ON library_items (url)", diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryTest.kt index 9921fac..53f9ebb 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryTest.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.data.repository import io.aatricks.easyreader.data.local.LibraryDao import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.model.ContentType +import io.aatricks.easyreader.data.model.FRACTION_UNKNOWN import io.aatricks.easyreader.data.model.LibraryItem import io.aatricks.easyreader.util.FieldUpdate import kotlinx.coroutines.flow.flowOf @@ -76,21 +77,23 @@ class LibraryRepositoryTest { title = "Test", url = "url", lastReadOffsetFraction = 0.5f, - lastReadIndex = 10 + lastReadIndex = 10, + lastReadElementKey = "img:https://cdn/x.jpg" ) whenever(libraryDao.getItemById(itemId)).thenReturn(item) - // Set new value for index, clear fraction, keep others unchanged repository.updateProgressExplicit( itemId = itemId, lastReadIndex = FieldUpdate.Set(20), - lastReadOffsetFraction = FieldUpdate.Clear + lastReadElementKey = FieldUpdate.Set("img:https://cdn/new.jpg"), + lastReadOffsetFraction = FieldUpdate.Set(FRACTION_UNKNOWN) ) verify(libraryDao).insertItem(check { assertEquals(20, it.lastReadIndex) - assertNull(it.lastReadOffsetFraction) - assertEquals("Test", it.title) // Unchanged + assertEquals("img:https://cdn/new.jpg", it.lastReadElementKey) + assertEquals(FRACTION_UNKNOWN, it.lastReadOffsetFraction) + assertEquals("Test", it.title) }) } @@ -101,15 +104,17 @@ class LibraryRepositoryTest { id = itemId, title = "Test", url = "url", - lastReadOffsetFraction = 0.5f + lastReadOffsetFraction = 0.5f, + lastReadElementKey = "img:abc" ) whenever(libraryDao.getItemById(itemId)).thenReturn(item) - // Use updateProgress (old method) which should preserve nullable fields if passed as null - repository.updateProgress(itemId, "Chapter 1", 10, lastReadOffsetFraction = null) + // Passing null for opt-out fields should leave them unchanged. + repository.updateProgress(itemId, "Chapter 1", 10, lastReadOffsetFraction = null, lastReadElementKey = null) verify(libraryDao).insertItem(check { assertEquals(0.5f, it.lastReadOffsetFraction) + assertEquals("img:abc", it.lastReadElementKey) }) } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderContentAreaLifecycleTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderContentAreaLifecycleTest.kt deleted file mode 100644 index 300fd77..0000000 --- a/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderContentAreaLifecycleTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.aatricks.easyreader.ui.screens - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class ReaderContentAreaLifecycleTest { - - @Test - fun `calculateReaderScrollSnapshot computes list metrics from current lazy state`() { - val snapshot = calculateReaderScrollSnapshot( - firstVisibleItemIndex = 5, - firstVisibleItemMeasuredIndex = 5, - firstVisibleItemScrollOffset = 120, - canScrollForward = true, - totalItemsCount = 8, - viewportHeightPx = 900, - firstVisibleItemSize = 300 - ) - - requireNotNull(snapshot) - assertEquals(5.4f, snapshot.scrollOffset, 0.0001f) - assertEquals(10f, snapshot.maxScrollOffset, 0.0001f) - assertEquals(3f, snapshot.viewportHeightInItems, 0.0001f) - assertEquals(5, snapshot.index) - assertEquals(120, snapshot.offset) - assertEquals(300, snapshot.firstVisibleItemSize) - } - - @Test - fun `calculateReaderScrollSnapshot returns null when first item size is unstable`() { - val snapshot = calculateReaderScrollSnapshot( - firstVisibleItemIndex = 0, - firstVisibleItemMeasuredIndex = 0, - firstVisibleItemScrollOffset = 0, - canScrollForward = true, - totalItemsCount = 5, - viewportHeightPx = 500, - firstVisibleItemSize = 0 - ) - - assertNull(snapshot) - } - - @Test - fun `flushReaderLifecycleProgress updates scroll before persist`() { - val order = mutableListOf() - val snapshot = ReaderScrollSnapshot( - scrollOffset = 2.5f, - maxScrollOffset = 8f, - viewportHeightInItems = 2f, - index = 2, - offset = 30, - canScrollForward = true, - firstVisibleItemSize = 400 - ) - - flushReaderLifecycleProgress( - snapshot = snapshot, - updateScrollPosition = { order += "update" }, - persistProgress = { order += "persist" } - ) - - assertEquals(listOf("update", "persist"), order) - } -} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/util/ReaderRestoreTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/util/ReaderRestoreTest.kt deleted file mode 100644 index 64d0f6b..0000000 --- a/app/src/test/java/io/aatricks/easyreader/ui/util/ReaderRestoreTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.aatricks.easyreader.ui.util - -import org.junit.Assert.assertEquals -import org.junit.Test - -class ReaderRestoreTest { - - @Test - fun `resolveRestoreOffset prefers normalized fraction when item size is known`() { - assertEquals( - 70, - resolveRestoreOffset( - savedOffsetPx = 140, - savedOffsetFraction = 0.35f, - itemSizePx = 200 - ) - ) - } - - @Test - fun `resolveRestoreOffset falls back to saved pixels when item size is unknown`() { - assertEquals( - 140, - resolveRestoreOffset( - savedOffsetPx = 140, - savedOffsetFraction = 0.35f, - itemSizePx = 0 - ) - ) - } - - @Test - fun `resolveRestoreOffset clamps normalized fraction into item bounds`() { - assertEquals( - 200, - resolveRestoreOffset( - savedOffsetPx = 10, - savedOffsetFraction = 1.5f, - itemSizePx = 200 - ) - ) - } -} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt index 7825e4f..b09457d 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.kotlin.* @@ -20,23 +22,29 @@ class ReaderProgressControllerTest { fun `saveCurrentProgress sends correct values to repository`() = runTest { val controller = ReaderProgressController(libraryRepository, this) controller.currentLibraryItemId = "test-id" - + val content = ChapterContent( paragraphs = listOf(ContentElement.Text("Hello")), title = "Chapter 1", url = "http://example.com/1" ) - + controller.syncProgressState( - scrollPosition = 50f, - scrollProgress = 50, - scrollIndex = 10, - scrollOffset = 100, - scrollOffsetFraction = 0.5f + ReaderProgressState( + scrollPosition = 50f, + scrollProgress = 50, + scrollIndex = 10, + scrollElementKey = "txt:http://example.com/1:10:abc", + scrollOffsetFraction = 0.5f, + firstVisibleItemSize = 500 + ) ) - + // Mark the user as having interacted so the controller treats the live state as truth. + controller.hasUserInteractedSinceLoad = true + controller.suppressAutoNavUntilUserInteraction = false + controller.saveCurrentProgress(content) - + verify(libraryRepository).updateProgressExplicit( itemId = eq("test-id"), currentChapter = eq(""), @@ -44,15 +52,57 @@ class ReaderProgressControllerTest { currentChapterUrl = eq(FieldUpdate.Set("http://example.com/1")), lastScrollProgress = eq(FieldUpdate.Set(50f)), lastReadIndex = eq(FieldUpdate.Set(10)), - lastReadOffset = eq(FieldUpdate.Set(100)), + lastReadElementKey = eq(FieldUpdate.Set("txt:http://example.com/1:10:abc")), lastReadOffsetFraction = eq(FieldUpdate.Set(0.5f)) ) } @Test - fun `calculateInitialScroll restores from library item`() = runTest { + fun `calculateInitialPosition restores via element key when present`() = runTest { + val controller = ReaderProgressController(libraryRepository, this) + + val paragraphs = List(30) { ContentElement.Text("Text $it") } + val targetIndex = 17 + val targetKey = stableContentElementKey("http://example.com/1", targetIndex, paragraphs[targetIndex]) + + val libraryItem = LibraryItem( + id = "test-id", + url = "http://example.com/novel", + title = "Novel", + currentChapter = "Chapter 1", + currentChapterUrl = "http://example.com/1", + progress = 75, + lastScrollPosition = 75f, + // Wrong index — element key must win. + lastReadIndex = 5, + lastReadElementKey = targetKey, + lastReadOffsetFraction = 0.5f, + contentType = ContentType.WEB + ) + + val content = ChapterContent( + paragraphs = paragraphs, + title = "Chapter 1", + url = "http://example.com/1" + ) + + val state = controller.calculateInitialPosition( + content = content, + libraryItem = libraryItem, + fromBottom = false, + isExplicitNavigation = false + ) + + assertEquals(targetIndex, state.scrollIndex) + assertEquals(targetKey, state.scrollElementKey) + assertEquals(0.5f, state.scrollOffsetFraction) + assertEquals(75f, state.scrollPosition) + } + + @Test + fun `calculateInitialPosition falls back to saved index when element key is empty`() = runTest { val controller = ReaderProgressController(libraryRepository, this) - + val libraryItem = LibraryItem( id = "test-id", url = "http://example.com/novel", @@ -62,36 +112,75 @@ class ReaderProgressControllerTest { progress = 75, lastScrollPosition = 75f, lastReadIndex = 20, - lastReadOffset = 200, - lastReadOffsetFraction = 0.75f, + lastReadElementKey = "", + lastReadOffsetFraction = 0.25f, contentType = ContentType.WEB ) - + val content = ChapterContent( paragraphs = List(30) { ContentElement.Text("Text $it") }, title = "Chapter 1", url = "http://example.com/1" ) - - val scrollState = controller.calculateInitialScroll( + + val state = controller.calculateInitialPosition( + content = content, + libraryItem = libraryItem, + fromBottom = false, + isExplicitNavigation = false + ) + + assertEquals(20, state.scrollIndex) + assertEquals(0.25f, state.scrollOffsetFraction) + // Resolver refreshes the element key from the resolved index for future saves. + val expectedKey = stableContentElementKey("http://example.com/1", 20, content.paragraphs[20]) + assertEquals(expectedKey, state.scrollElementKey) + } + + @Test + fun `calculateInitialPosition derives index from percent for legacy rows`() = runTest { + val controller = ReaderProgressController(libraryRepository, this) + + // No key, no offset, no fraction sentinel — pre-unification legacy row. + val libraryItem = LibraryItem( + id = "test-id", + url = "http://example.com/novel", + title = "Novel", + currentChapter = "Chapter 1", + currentChapterUrl = "http://example.com/1", + progress = 50, + lastScrollPosition = 50f, + lastReadIndex = 0, + lastReadElementKey = "", + lastReadOffsetFraction = FRACTION_UNKNOWN, + contentType = ContentType.WEB + ) + + val content = ChapterContent( + paragraphs = List(101) { ContentElement.Text("Text $it") }, + title = "Chapter 1", + url = "http://example.com/1" + ) + + val state = controller.calculateInitialPosition( content = content, libraryItem = libraryItem, fromBottom = false, isExplicitNavigation = false ) - - assertEquals(20, scrollState.index) - assertEquals(75f, scrollState.position) - assertEquals(75, scrollState.progress) - assertEquals(0, scrollState.offset) // offset is 0 because offsetFraction is present - assertEquals(0.75f, scrollState.offsetFraction) - assertEquals(75f, scrollState.targetPosition) + + // 50% of (101-1) = 50 + assertEquals(50, state.scrollIndex) + assertEquals(0f, state.scrollOffsetFraction) + assertEquals(50f, state.scrollPosition) + assertTrue(state.scrollElementKey.isNotEmpty()) + assertEquals(50, controller.restoredProgressSnapshot?.scrollIndex) } @Test - fun `calculateInitialScroll for explicit navigation starts from top`() = runTest { + fun `calculateInitialPosition for explicit navigation starts from top`() = runTest { val controller = ReaderProgressController(libraryRepository, this) - + val libraryItem = LibraryItem( id = "test-id", url = "http://example.com/novel", @@ -101,25 +190,24 @@ class ReaderProgressControllerTest { progress = 75, contentType = ContentType.WEB ) - + val content = ChapterContent( paragraphs = List(30) { ContentElement.Text("Text $it") }, title = "Chapter 1", url = "http://example.com/1" ) - - val scrollState = controller.calculateInitialScroll( + + val state = controller.calculateInitialPosition( content = content, libraryItem = libraryItem, fromBottom = false, isExplicitNavigation = true ) - - assertEquals(0, scrollState.index) - assertEquals(0f, scrollState.position) - assertEquals(0, scrollState.progress) - assertEquals(0, scrollState.offset) - assertEquals(0f, scrollState.offsetFraction) + + assertEquals(0, state.scrollIndex) + assertEquals(0f, state.scrollPosition) + assertEquals(0f, state.scrollOffsetFraction) + assertEquals("", state.scrollElementKey) } @Test @@ -128,7 +216,7 @@ class ReaderProgressControllerTest { controller.suppressAutoNavUntilUserInteraction = true controller.hasUserInteractedSinceLoad = false controller.restoredProgressSnapshot = ReaderProgressState(scrollPosition = 50f) - + controller.onUserInteraction( uiTargetScrollPosition = 50f, uiPendingRestoreOffsetFraction = 0.5f, @@ -137,14 +225,14 @@ class ReaderProgressControllerTest { assertNull(offset) } ) - + assertEquals(true, controller.hasUserInteractedSinceLoad) assertEquals(false, controller.suppressAutoNavUntilUserInteraction) assertNull(controller.restoredProgressSnapshot) } @Test - fun `updateScrollPosition skips persistence for tiny long-strip image samples`() = runTest { + fun `updateScrollPosition skips persistence when item size is below stability threshold`() = runTest { val controller = ReaderProgressController(libraryRepository, this) controller.currentLibraryItemId = "test-id" val content = ChapterContent( @@ -162,7 +250,8 @@ class ReaderProgressControllerTest { maxScrollOffset = 10f, viewportHeight = 1f, index = 1, - offset = 40, + offsetFraction = 0.3f, + elementKey = "img:https://cdn.example.com/2.jpg", content = content, canScrollForward = true, firstVisibleItemSize = 48 @@ -170,12 +259,46 @@ class ReaderProgressControllerTest { advanceTimeBy(200) runCurrent() + // Index updates immediately for UI tracking, but no DB write while size is unstable. assertEquals(1, controller.progressState.value.scrollIndex) - assertEquals(40, controller.progressState.value.scrollOffset) + assertEquals(FRACTION_UNKNOWN, controller.progressState.value.scrollOffsetFraction) verify(libraryRepository, never()).updateProgressExplicit(any(), any(), any(), any(), any(), any(), any(), any()) } - private fun assertNull(value: Any?) { - org.junit.Assert.assertNull(value) + @Test + fun `isSnapshotPersistable rejects unstable and unknown fraction states`() = runTest { + val controller = ReaderProgressController(libraryRepository, this) + val content = ChapterContent( + paragraphs = listOf(ContentElement.Text("Hello")), + title = "Chapter 1", + url = "http://example.com/1" + ) + + val unstable = ReaderProgressState( + scrollPosition = 50f, + scrollProgress = 50, + scrollIndex = 0, + scrollOffsetFraction = 0.5f, + firstVisibleItemSize = 40 // below MIN_STABLE_ITEM_SIZE_PX + ) + assertEquals(false, controller.isSnapshotPersistable(content, unstable)) + + val unknown = ReaderProgressState( + scrollPosition = 50f, + scrollProgress = 50, + scrollIndex = 0, + scrollOffsetFraction = FRACTION_UNKNOWN, + firstVisibleItemSize = 500 + ) + assertEquals(false, controller.isSnapshotPersistable(content, unknown)) + + val stable = ReaderProgressState( + scrollPosition = 50f, + scrollProgress = 50, + scrollIndex = 0, + scrollOffsetFraction = 0.5f, + firstVisibleItemSize = 500 + ) + assertEquals(true, controller.isSnapshotPersistable(content, stable)) } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt index c432ec3..7535cf1 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt @@ -106,22 +106,20 @@ class ReaderViewModelTest { @Test fun `loadContent saves current progress before loading new`() = runTest { - // Setup initial item val initialItemId = "item-1" val initialUrl = "https://example.com/1" - // Mock success for first load val result1 = ContentResult.Success( - elements = emptyList(), + elements = listOf(ContentElement.Text("Initial body")), title = "Title 1", url = initialUrl ) whenever(contentRepository.loadContent(initialUrl)).thenReturn(result1) whenever(libraryRepository.getItemByUrl(initialUrl)).thenReturn( - LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl) + LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl, progress = 30, lastScrollPosition = 30f) ) whenever(libraryRepository.getItemById(initialItemId)).thenReturn( - LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl) + LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl, progress = 30, lastScrollPosition = 30f) ) viewModel.loadContent(initialUrl) @@ -129,16 +127,14 @@ class ReaderViewModelTest { assertEquals(initialUrl, viewModel.uiState.value.content?.url) - // Now load a second item val nextUrl = "https://example.com/2" whenever(contentRepository.loadContent(nextUrl)).thenReturn( - ContentResult.Success(emptyList(), "Title 2", nextUrl) + ContentResult.Success(listOf(ContentElement.Text("Next body")), "Title 2", nextUrl) ) viewModel.loadContent(nextUrl) advanceUntilIdle() - // Verify updateProgress was called for the INITIAL item verify(libraryRepository).updateProgressExplicit( itemId = eq(initialItemId), currentChapter = any(), @@ -146,7 +142,7 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(initialUrl)), lastScrollProgress = any(), lastReadIndex = any(), - lastReadOffset = any(), + lastReadElementKey = any(), lastReadOffsetFraction = any() ) } @@ -163,7 +159,6 @@ class ReaderViewModelTest { contentType = ContentType.PDF, progress = 55, lastReadIndex = savedPageIndex, - lastReadOffset = 18, lastScrollPosition = 55f ) val preloadedPages = List(savedPageIndex) { index -> @@ -193,7 +188,7 @@ class ReaderViewModelTest { url = url, progress = 55, lastReadIndex = 4, - lastReadOffset = 18, + lastReadOffsetFraction = 0.3f, lastScrollPosition = 55f ) @@ -206,7 +201,6 @@ class ReaderViewModelTest { advanceUntilIdle() assertEquals(0, viewModel.uiState.value.scrollIndex) - assertEquals(0, viewModel.uiState.value.scrollOffset) assertEquals(0, viewModel.uiState.value.scrollProgress) assertEquals(0f, viewModel.uiState.value.scrollPosition, 0.001f) } @@ -221,12 +215,12 @@ class ReaderViewModelTest { url = url, progress = 55, lastReadIndex = 4, - lastReadOffset = 18, + lastReadOffsetFraction = 0.6f, lastScrollPosition = 55f ) whenever(contentRepository.loadContent(url)).thenReturn( - ContentResult.Success(listOf(ContentElement.Text("Chapter content")), "Chapter 10", url) + ContentResult.Success(List(10) { ContentElement.Text("Paragraph $it") }, "Chapter 10", url) ) whenever(libraryRepository.getItemById(itemId)).thenReturn(savedItem) @@ -234,39 +228,42 @@ class ReaderViewModelTest { advanceUntilIdle() assertEquals(4, viewModel.uiState.value.scrollIndex) - assertEquals(18, viewModel.uiState.value.scrollOffset) assertEquals(55, viewModel.uiState.value.scrollProgress) assertEquals(55f, viewModel.uiState.value.scrollPosition, 0.001f) + assertEquals(0.6f, viewModel.uiState.value.restoreOffsetFraction, 0.001f) } @Test - fun `loadContent defers raw offset when saved normalized anchor exists`() = runTest { + fun `loadContent restores via element key when content is reparsed`() = runTest { val itemId = "web-item" val url = "https://example.com/chapter-10" + val paragraphs = List(10) { ContentElement.Text("Paragraph $it") } + val targetIndex = 6 + val targetKey = stableContentElementKey(url, targetIndex, paragraphs[targetIndex]) + val savedItem = LibraryItem( id = itemId, title = "Chapter 10", url = url, progress = 55, - lastReadIndex = 4, - lastReadOffset = 180, - lastReadOffsetFraction = 0.3f, + // Wrong index — element key must override. + lastReadIndex = 2, + lastReadElementKey = targetKey, + lastReadOffsetFraction = 0.4f, lastScrollPosition = 55f ) whenever(contentRepository.loadContent(url)).thenReturn( - ContentResult.Success(listOf(ContentElement.Text("Chapter content")), "Chapter 10", url) + ContentResult.Success(paragraphs, "Chapter 10", url) ) whenever(libraryRepository.getItemById(itemId)).thenReturn(savedItem) viewModel.loadContent(url, itemId) advanceUntilIdle() - assertEquals(4, viewModel.uiState.value.scrollIndex) - assertEquals(0, viewModel.uiState.value.scrollOffset) - assertEquals(0.3f, viewModel.uiState.value.pendingRestoreOffsetFraction ?: 0f, 0.001f) - assertEquals(55, viewModel.uiState.value.scrollProgress) - assertEquals(55f, viewModel.uiState.value.scrollPosition, 0.001f) + assertEquals(targetIndex, viewModel.uiState.value.scrollIndex) + assertEquals(targetKey, viewModel.uiState.value.restoreElementKey) + assertEquals(0.4f, viewModel.uiState.value.restoreOffsetFraction, 0.001f) } @Test @@ -279,7 +276,7 @@ class ReaderViewModelTest { url = url, progress = 0, lastReadIndex = 2, - lastReadOffset = 24, + lastReadOffsetFraction = 0.1f, lastScrollPosition = 5f ) @@ -292,7 +289,6 @@ class ReaderViewModelTest { advanceUntilIdle() assertEquals(0, viewModel.uiState.value.scrollIndex) - assertEquals(0, viewModel.uiState.value.scrollOffset) assertEquals(0, viewModel.uiState.value.scrollProgress) assertEquals(0f, viewModel.uiState.value.scrollPosition, 0.001f) } @@ -302,7 +298,6 @@ class ReaderViewModelTest { val itemId = "item-1" val url = "https://example.com/1" - // Set up current item whenever(contentRepository.loadContent(url)).thenReturn( ContentResult.Success(listOf(ContentElement.Text("Test")), "Test", url) ) @@ -315,21 +310,24 @@ class ReaderViewModelTest { viewModel.loadContent(url) advanceUntilIdle() - viewModel.onUserInteraction() - // Update scroll - viewModel.updateScrollPosition(50f, 100f, 10f, 5, 10) + viewModel.updateScrollPosition( + scrollOffset = 50f, + maxScrollOffset = 100f, + viewportHeight = 10f, + index = 5, + offsetFraction = 0.1f, + elementKey = "txt:$url:5:foo", + firstVisibleItemSize = 200 + ) - // Should NOT have saved yet (debounced) verify(libraryRepository, never()).updateProgressExplicit(any(), any(), any(), any(), any(), any(), any(), any()) - // Advance time advanceTimeBy(200) runCurrent() advanceUntilIdle() - // Now it should have saved verify(libraryRepository).updateProgressExplicit( itemId = eq(itemId), currentChapter = any(), @@ -337,8 +335,8 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = any(), lastReadIndex = eq(FieldUpdate.Set(5)), - lastReadOffset = eq(FieldUpdate.Set(10)), - lastReadOffsetFraction = any() + lastReadElementKey = eq(FieldUpdate.Set("txt:$url:5:foo")), + lastReadOffsetFraction = eq(FieldUpdate.Set(0.1f)) ) } @@ -366,23 +364,25 @@ class ReaderViewModelTest { maxScrollOffset = 100f, viewportHeight = 10f, index = 2, - offset = 100, + offsetFraction = 1.0f, + elementKey = "txt:$url:2:foo", firstVisibleItemSize = 100 ) - val stateAfterFirstUpdate = viewModel.uiState.value + val progressAfterFirst = viewModel.progressState.value + // Same index, fraction barely changes — should not write again. viewModel.updateScrollPosition( scrollOffset = 30.1f, maxScrollOffset = 100f, viewportHeight = 10f, index = 2, - offset = 103, + offsetFraction = 1.001f, + elementKey = "txt:$url:2:foo", firstVisibleItemSize = 100 ) - val stateAfterSecondUpdate = viewModel.uiState.value - assertEquals(stateAfterFirstUpdate.scrollOffset, stateAfterSecondUpdate.scrollOffset) - assertEquals(stateAfterFirstUpdate.scrollPosition, stateAfterSecondUpdate.scrollPosition, 0.001f) + val progressAfterSecond = viewModel.progressState.value + assertEquals(progressAfterFirst.scrollPosition, progressAfterSecond.scrollPosition, 0.001f) advanceTimeBy(200) runCurrent() @@ -395,7 +395,7 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = any(), lastReadIndex = eq(FieldUpdate.Set(2)), - lastReadOffset = eq(FieldUpdate.Set(100)), + lastReadElementKey = eq(FieldUpdate.Set("txt:$url:2:foo")), lastReadOffsetFraction = eq(FieldUpdate.Set(1f)) ) } @@ -426,22 +426,24 @@ class ReaderViewModelTest { maxScrollOffset = 100f, viewportHeight = 10f, index = 5, - offset = 10, + offsetFraction = 0.1f, + elementKey = "txt:$url:5:abc", firstVisibleItemSize = 100 ) val uiStateAfterScroll = viewModel.uiState.value val progressState = viewModel.progressState.value + // Scroll updates push into progressState only — uiState's position-display fields stay + // as the per-load initial values so the bottom bar doesn't fight active scrolling. assertEquals(uiStateBeforeScroll.scrollPosition, uiStateAfterScroll.scrollPosition, 0.001f) assertEquals(uiStateBeforeScroll.scrollProgress, uiStateAfterScroll.scrollProgress) assertEquals(uiStateBeforeScroll.scrollIndex, uiStateAfterScroll.scrollIndex) - assertEquals(uiStateBeforeScroll.scrollOffset, uiStateAfterScroll.scrollOffset) assertEquals(55, progressState.scrollProgress) assertEquals(55.555f, progressState.scrollPosition, 0.01f) assertEquals(5, progressState.scrollIndex) - assertEquals(10, progressState.scrollOffset) - assertEquals(0.1f, progressState.scrollOffsetFraction ?: 0f, 0.001f) + assertEquals("txt:$url:5:abc", progressState.scrollElementKey) + assertEquals(0.1f, progressState.scrollOffsetFraction, 0.001f) } @Test @@ -462,15 +464,7 @@ class ReaderViewModelTest { viewModel.loadContent(url) advanceUntilIdle() - viewModel.updateScrollPosition( - scrollOffset = 5f, - maxScrollOffset = 100f, - viewportHeight = 0f, - index = 1, - offset = 15, - firstVisibleItemSize = 100 - ) - + // Note: no user interaction → restoredProgressSnapshot wins. Initial position = top. viewModel.persistLifecycleProgress() advanceUntilIdle() @@ -481,7 +475,7 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = eq(FieldUpdate.Set(0f)), lastReadIndex = eq(FieldUpdate.Set(0)), - lastReadOffset = eq(FieldUpdate.Set(0)), + lastReadElementKey = eq(FieldUpdate.Set("")), lastReadOffsetFraction = eq(FieldUpdate.Set(0f)) ) } @@ -490,19 +484,21 @@ class ReaderViewModelTest { fun `persistLifecycleProgress preserves restored anchor before user interaction`() = runTest { val itemId = "item-1" val url = "https://example.com/manwha/1" + val paragraphs = List(10) { ContentElement.Text("Paragraph $it") } + val anchorKey = stableContentElementKey(url, 3, paragraphs[3]) val savedItem = LibraryItem( id = itemId, title = "Chapter 1", url = url, progress = 57, lastReadIndex = 3, - lastReadOffset = 140, + lastReadElementKey = anchorKey, lastReadOffsetFraction = 0.35f, lastScrollPosition = 57f ) whenever(contentRepository.loadContent(url)).thenReturn( - ContentResult.Success(listOf(ContentElement.Text("Test")), "Test", url) + ContentResult.Success(paragraphs, "Test", url) ) whenever(libraryRepository.getItemById(itemId)).thenReturn(savedItem) @@ -510,12 +506,14 @@ class ReaderViewModelTest { advanceUntilIdle() clearInvocations(libraryRepository) + // Simulate a placeholder-sized measurement after restore — should NOT pollute persistence. viewModel.updateScrollPosition( scrollOffset = 60f, maxScrollOffset = 100f, viewportHeight = 0f, index = 4, - offset = 320, + offsetFraction = 0.4f, + elementKey = "txt:other", firstVisibleItemSize = 800 ) @@ -529,7 +527,7 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = eq(FieldUpdate.Set(57f)), lastReadIndex = eq(FieldUpdate.Set(3)), - lastReadOffset = eq(FieldUpdate.Set(140)), + lastReadElementKey = eq(FieldUpdate.Set(anchorKey)), lastReadOffsetFraction = eq(FieldUpdate.Set(0.35f)) ) } @@ -558,7 +556,8 @@ class ReaderViewModelTest { maxScrollOffset = 100f, viewportHeight = 0f, index = 2, - offset = 30, + offsetFraction = 0.25f, + elementKey = "txt:live-anchor", firstVisibleItemSize = 120 ) advanceTimeBy(200) @@ -575,7 +574,7 @@ class ReaderViewModelTest { currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = eq(FieldUpdate.Set(12f)), lastReadIndex = eq(FieldUpdate.Set(2)), - lastReadOffset = eq(FieldUpdate.Set(30)), + lastReadElementKey = eq(FieldUpdate.Set("txt:live-anchor")), lastReadOffsetFraction = eq(FieldUpdate.Set(0.25f)) ) } @@ -604,8 +603,9 @@ class ReaderViewModelTest { scrollOffset = 33f, maxScrollOffset = 100f, viewportHeight = 0f, - index = 5, - offset = 120, + index = 0, + offsetFraction = 0.4f, + elementKey = "img:https://cdn.example.com/1.jpg", firstVisibleItemSize = 300 ) @@ -620,8 +620,8 @@ class ReaderViewModelTest { progress = eq(FieldUpdate.Set(33)), currentChapterUrl = eq(FieldUpdate.Set(url)), lastScrollProgress = eq(FieldUpdate.Set(33f)), - lastReadIndex = eq(FieldUpdate.Set(5)), - lastReadOffset = eq(FieldUpdate.Set(120)), + lastReadIndex = eq(FieldUpdate.Set(0)), + lastReadElementKey = eq(FieldUpdate.Set("img:https://cdn.example.com/1.jpg")), lastReadOffsetFraction = eq(FieldUpdate.Set(0.4f)) ) } @@ -636,7 +636,7 @@ class ReaderViewModelTest { url = url, progress = 64, lastReadIndex = 5, - lastReadOffset = 220, + lastReadElementKey = "img:https://cdn.example.com/panel-6.jpg", lastReadOffsetFraction = 0.4f, lastScrollPosition = 64f ) @@ -663,14 +663,12 @@ class ReaderViewModelTest { viewModel.loadContent(url, itemId) advanceUntilIdle() assertEquals(5, viewModel.uiState.value.scrollIndex) - assertEquals(0.4f, viewModel.uiState.value.pendingRestoreOffsetFraction ?: 0f, 0.001f) - assertEquals("https://cdn.example.com/panel-6.jpg", (viewModel.uiState.value.content?.paragraphs?.get(5) as ContentElement.Image).url) + assertEquals(0.4f, viewModel.uiState.value.restoreOffsetFraction, 0.001f) viewModel.loadContent(url, itemId) advanceUntilIdle() assertEquals(5, viewModel.uiState.value.scrollIndex) - assertEquals(0.4f, viewModel.uiState.value.pendingRestoreOffsetFraction ?: 0f, 0.001f) - assertEquals("https://cdn.example.com/panel-6.jpg", (viewModel.uiState.value.content?.paragraphs?.get(5) as ContentElement.Image).url) + assertEquals(0.4f, viewModel.uiState.value.restoreOffsetFraction, 0.001f) } @Test @@ -718,7 +716,6 @@ class ReaderViewModelTest { val itemId = "item-1" val url = "https://example.com/pdf" - // Setup item with placeholder content val placeholderContent = listOf(ContentElement.Text("Loading page 5...")) whenever(contentRepository.loadContent(url)).thenReturn( ContentResult.Success(placeholderContent, "PDF", url) @@ -733,27 +730,22 @@ class ReaderViewModelTest { viewModel.loadContent(url) advanceUntilIdle() - // Try to update progress while index 0 is a placeholder - viewModel.updateReadingProgress(50, 50f, 0, 0) + viewModel.updateReadingProgress(50, 50f, 0, "", 0f) advanceUntilIdle() - // Should NOT have called libraryRepository.updateProgress verify(libraryRepository, never()).updateProgressExplicit(any(), any(), any(), any(), any(), any(), any(), any()) - // Now setup item with REAL content val realContent = listOf(ContentElement.Text("Real page content")) whenever(contentRepository.loadContent(url)).thenReturn( ContentResult.Success(realContent, "PDF", url) ) - + viewModel.loadContent(url) advanceUntilIdle() - // Try to update progress while index 0 is real content - viewModel.updateReadingProgress(60, 60f, 0, 0) + viewModel.updateReadingProgress(60, 60f, 0, "txt:$url:0:abc", 0f) advanceUntilIdle() - // Should HAVE called libraryRepository.updateProgress verify(libraryRepository).updateProgressExplicit( itemId = eq(itemId), currentChapter = any(), @@ -761,7 +753,7 @@ class ReaderViewModelTest { currentChapterUrl = any(), lastScrollProgress = eq(FieldUpdate.Set(60f)), lastReadIndex = eq(FieldUpdate.Set(0)), - lastReadOffset = eq(FieldUpdate.Set(0)), + lastReadElementKey = eq(FieldUpdate.Set("txt:$url:0:abc")), lastReadOffsetFraction = any() ) } @@ -818,8 +810,6 @@ class ReaderViewModelTest { currentChapterNumber = 5.0 ) - // Distances from 5: c1=4, c2=3, c3=2. All > 1 → all candidates. Downloaded chapters - // are no longer skipped: voluntary downloads also auto-purge once read. assertEquals(listOf("1", "2", "3"), toDelete.map { it.id }.sorted()) } From d7bda731a873008611639d1dfd1d4ec346c4ba22 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Thu, 21 May 2026 11:18:19 -0400 Subject: [PATCH 06/19] Add DownloadStatusReconciler --- .../repository/DownloadStatusReconciler.kt | 97 +++++++++ .../data/repository/content/ImageCache.kt | 9 +- .../repository/content/WebContentLoader.kt | 26 ++- .../ui/viewmodel/LibraryViewModel.kt | 128 +++++++---- .../easyreader/work/ChapterDownloadWorker.kt | 18 +- .../DownloadStatusReconcilerTest.kt | 204 ++++++++++++++++++ .../ui/viewmodel/LibraryViewModelTest.kt | 62 +++++- .../work/ChapterDownloadWorkerTest.kt | 90 +++++++- 8 files changed, 568 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/data/repository/DownloadStatusReconcilerTest.kt diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt new file mode 100644 index 0000000..3e8b363 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/DownloadStatusReconciler.kt @@ -0,0 +1,97 @@ +package io.aatricks.easyreader.data.repository + +import android.util.Log +import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.data.model.PrefetchResult +import io.aatricks.easyreader.util.UrlSanitizer +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Canonical writer for the [LibraryItem.isDownloaded] flag. Every producer of a + * download/inspect result (VM in-process prefetch, ChapterDownloadWorker, startup + * verifier, refresh) routes through here so the badge, the DB flag, and the + * on-disk image state cannot disagree. + * + * Promotion: flag → true when [isFullyDownloaded]. + * + * Demotion: flag → false when the result is terminal (not in progress) AND came + * from a downloads-tier inspect (`wasUserInspect = true`) AND the chapter is not + * fully present on disk. This covers three real failure modes: + * - hasPermanentFailures=true (4xx images recorded in the failure store) + * - isComplete=false (transient failures that never recovered after retries) + * - isPersistentDownload=false (downloads-tier HTML was lost / never written) + * + * SPECULATIVE / all-tier inspects pass `wasUserInspect = false` so cache-tier + * lookups never knock the flag down. + */ +@Singleton +class DownloadStatusReconciler @Inject constructor( + private val libraryRepository: LibraryRepository +) { + fun isFullyDownloaded(result: PrefetchResult): Boolean = + result.isPersistentDownload && + result.isComplete && + !result.hasPermanentFailures + + suspend fun reconcile( + item: LibraryItem, + result: PrefetchResult, + wasUserInspect: Boolean + ) { + if (isFullyDownloaded(result)) { + val action = if (!item.isDownloaded) "promote" else "noop-already-true" + logAction(item, result, wasUserInspect, action) + if (!item.isDownloaded) { + libraryRepository.markDownloaded(item.id, true) + } + return + } + if (!wasUserInspect) { + logAction(item, result, wasUserInspect, "noop-not-user-inspect") + return + } + if (result.isInProgress) { + logAction(item, result, wasUserInspect, "noop-in-progress") + return + } + val action = if (item.isDownloaded) "demote" else "noop-already-false" + logAction(item, result, wasUserInspect, action) + if (item.isDownloaded) { + libraryRepository.markDownloaded(item.id, false) + } + } + + // TODO(verification): remove this logging after the bulk-download regression is + // confirmed fixed on a real device. The fields here are the inputs to every + // download-status decision so a logcat trace tells us exactly why a chapter + // ended up in the wrong state if the bug recurs. + private fun logAction( + item: LibraryItem, + result: PrefetchResult, + wasUserInspect: Boolean, + action: String + ) { + Log.w( + TAG, + "url=${UrlSanitizer.sanitize(item.url)} action=$action " + + "item.isDownloaded=${item.isDownloaded} " + + "isComplete=${result.isComplete} hasPerm=${result.hasPermanentFailures} " + + "inProgress=${result.isInProgress} persistent=${result.isPersistentDownload} " + + "userInspect=$wasUserInspect cached=${result.cachedImages}/${result.totalImages}" + ) + } + + private companion object { + private const val TAG = "DownloadReconciler" + } + + suspend fun reconcile( + url: String, + result: PrefetchResult, + wasUserInspect: Boolean + ) { + val item = libraryRepository.getItemByUrl(url) ?: return + reconcile(item, result, wasUserInspect) + } +} diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt index 8cd0400..8daeeff 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt @@ -91,11 +91,16 @@ class ImageCache @Inject constructor( ?: File(mediaCacheDir, url.hashCode().toString()).takeIf { it.exists() } ?: return null target.parentFile?.mkdirs() - return if (source.renameTo(target)) target else { + if (source.renameTo(target)) return target + // Cross-filesystem promotions fall back to copy+delete. A copyTo failure + // (disk full, permission, etc.) used to surface as an uncaught IOException + // and crash the calling cacheImages batch — return null so the caller treats + // this image as missing and the inspect path drives the correct demotion. + return runCatching { source.copyTo(target, overwrite = true) source.delete() target - } + }.getOrNull() } private fun primaryCachedMediaFile(url: String): File = 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 f80468b..d8b128e 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 @@ -12,6 +12,7 @@ import io.aatricks.easyreader.util.FileSizeUtils import io.aatricks.easyreader.util.HttpRetry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.Dispatchers @@ -251,7 +252,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( return@repeat } - val deferred = repositoryScope.async { + // CoroutineStart.LAZY so the deferred is created but not yet running until the + // first .await() / .start(). Registering the URL in the inFlight map before + // starting closes the race where a concurrent inspect could observe + // isInProgress=false during a prefetch that has begun executing but hasn't yet + // been added to the map. + val deferred = repositoryScope.async(start = CoroutineStart.LAZY) { executePrefetch(url, mode, onProgress) }.also { created -> created.invokeOnCompletion { @@ -766,14 +772,19 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( imageCache.findExistingCachedMediaFile(imageUrl) != null } } + // Never claim isComplete=true from a progress emission. Mid-flight we don't + // yet know whether permanent failures will appear, and emitting Downloaded + // before the final inspectCacheInternal produces a UI badge that contradicts + // what the reader is about to display. The terminal PrefetchResult returned + // by executePrefetch is the only authoritative "complete" emission. onProgress( PrefetchResult( url = url, htmlCached = true, totalImages = imageUrls.size, cachedImages = cached, - isComplete = cached == imageUrls.size && imageUrls.isNotEmpty(), - isInProgress = cached < imageUrls.size, + isComplete = false, + isInProgress = true, isRetryable = true, isPersistentDownload = mode == PrefetchMode.USER_REQUESTED ) @@ -943,7 +954,14 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( } val cachedFile = imageCache.destinationFile(imageUrl, writeTier) - val tempFile = File(cachedFile.parent, "${cachedFile.name}.tmp") + // Unique per attempt: a SPECULATIVE download that gets cancelled mid-write by an + // arriving USER_REQUESTED would otherwise race on the same `.tmp` path and + // interleave bytes. Random suffix isolates the two writers so the new attempt + // never observes partial data from the cancelled one. + val tempFile = File( + cachedFile.parent, + "${cachedFile.name}.${java.util.UUID.randomUUID()}.tmp" + ) if (priority == ImageRequestPriority.USER_REQUESTED) { val toCancel = imageDownloadMutex.withLock { 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 29048f2..5857e52 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 @@ -13,12 +13,15 @@ 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 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.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay @@ -27,6 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import javax.inject.Inject import android.util.Log @@ -36,7 +40,8 @@ class LibraryViewModel @Inject constructor( val repository: LibraryRepository, private val contentRepository: ContentRepository, private val exploreRepository: ExploreRepository, - private val downloadQueue: ChapterDownloadQueue + private val downloadQueue: ChapterDownloadQueue, + private val downloadStatusReconciler: DownloadStatusReconciler ) : BaseViewModel(LibraryUiState()) { private val TAG = "LibraryViewModel" @@ -119,13 +124,19 @@ class LibraryViewModel @Inject constructor( try { gate.withPermit { setCacheState(state.copy(isInProgress = true, isRetryable = false)) - runCatching { + runCatchingExceptCancel { val result = contentRepository.prefetchWithProgress( state.url, PrefetchMode.USER_REQUESTED ) { setCacheState(it) } - setCacheState(result) - syncDownloadedFlag(state.url, result) + withContext(NonCancellable) { + setCacheState(result) + downloadStatusReconciler.reconcile( + state.url, + result, + wasUserInspect = true + ) + } } } } finally { @@ -402,10 +413,11 @@ class LibraryViewModel @Inject constructor( // completed, so this is not a 2x cost for typical sessions. chapters.forEach { downloadQueue.enqueue(it.url) } viewModelScope.launch { - chapters.forEach { chapter -> - try { - runCatching { - val libraryItem = repository.getItemByUrl(chapter.url) + try { + chapters.forEach { chapter -> + try { + runCatchingExceptCancel { + val libraryItem = repository.getItemByUrl(chapter.url) ?: repository.addItem( title = chapter.title, url = chapter.url, @@ -432,43 +444,38 @@ class LibraryViewModel @Inject constructor( chapter.url, PrefetchMode.USER_REQUESTED ) { setCacheState(it) } - setCacheState(result) - syncDownloadedFlag(libraryItem, result) + // Cancellation between the inspect-driven final result and the DB + // write would leave the flag silently un-promoted (and the worker's + // duplicate path may not run for in-process flows). Lock this final + // commit out of cancellation; the prefetch itself was already + // cancellable above. + withContext(NonCancellable) { + setCacheState(result) + downloadStatusReconciler.reconcile( + libraryItem, + result, + wasUserInspect = true + ) + } + } + } finally { + contentRepository.endUserDownload(chapter.url) } - } finally { - contentRepository.endUserDownload(chapter.url) + } + } finally { + // If cancellation tore the loop down before reaching every chapter, drain + // the upfront beginUserDownload calls so userDownloadsInFlight doesn't keep + // suppressing auto-delete checks until the WorkManager workers eventually + // catch up. Set.remove is idempotent so re-entering is harmless. + withContext(NonCancellable) { + chapters.forEach { contentRepository.endUserDownload(it.url) } } } } } - private suspend fun syncDownloadedFlag(url: String, result: PrefetchResult) { - val item = repository.getItemByUrl(url) ?: return - syncDownloadedFlag(item, result) - } - - // The DB isDownloaded flag means "every image of this chapter is actually on disk and - // openable offline". Permanent failures (4xx images in the .failed sidecar) count toward - // isComplete (so the download loop and auto-resume stop), but they are NOT on disk and - // must not promote the flag — otherwise the chapter shows "Downloaded" while opening it - // surfaces "Image unavailable" for the missing images. - // - // Demotion is only permitted when we have a confident, terminal signal that the chapter - // is not fully downloaded: a USER_REQUESTED inspect that finished (not in progress) and - // either accepted permanent failures or could no longer find images that were previously - // counted. Transient inspect misses (e.g. images not yet inspected during startup) leave - // the flag alone. - private suspend fun syncDownloadedFlag(item: LibraryItem, result: PrefetchResult) { - if (!result.isPersistentDownload) return - val isFullyDownloaded = result.isComplete && !result.hasPermanentFailures - if (isFullyDownloaded) { - if (!item.isDownloaded) repository.markDownloaded(item.id, true) - return - } - if (item.isDownloaded && !result.isInProgress && result.hasPermanentFailures) { - repository.markDownloaded(item.id, false) - } - } + // All DB-flag writes go through [downloadStatusReconciler] so the badge, the DB flag, + // and on-disk state cannot disagree. See DownloadStatusReconciler for the rule. fun fetchAndAdd(url: String): Unit { viewModelScope.launch { @@ -585,13 +592,19 @@ class LibraryViewModel @Inject constructor( ) ) gate.withPermit { - runCatching { + runCatchingExceptCancel { val result = contentRepository.prefetchWithProgress( item.url, PrefetchMode.USER_REQUESTED ) { setCacheState(it) } - setCacheState(result) - syncDownloadedFlag(item.url, result) + withContext(NonCancellable) { + setCacheState(result) + downloadStatusReconciler.reconcile( + item, + result, + wasUserInspect = true + ) + } } } } finally { @@ -619,10 +632,12 @@ class LibraryViewModel @Inject constructor( (uiState.value.chapterCacheStates[url] ?: PrefetchResult(url, false, 0, 0, false)) .copy(isInProgress = true, isRetryable = false, isPersistentDownload = true) ) - runCatching { + runCatchingExceptCancel { val result = contentRepository.prefetchWithProgress(url, PrefetchMode.USER_REQUESTED) { setCacheState(it) } - setCacheState(result) - syncDownloadedFlag(url, result) + withContext(NonCancellable) { + setCacheState(result) + downloadStatusReconciler.reconcile(url, result, wasUserInspect = true) + } } } finally { contentRepository.endUserDownload(url) @@ -758,13 +773,18 @@ class LibraryViewModel @Inject constructor( } else { null } - val result = if (item?.isDownloaded == true || downloadResult.hasDownloadEvidence()) { + 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) { - syncDownloadedFlag(item, result) + downloadStatusReconciler.reconcile( + item, + result, + wasUserInspect = useDownloadResult + ) } result } @@ -839,6 +859,20 @@ class LibraryViewModel @Inject constructor( } } + // kotlin.runCatching catches Throwable including CancellationException, which silently + // breaks coroutine cancellation propagation. This variant rethrows cancellation so the + // enclosing scope tears down as intended; other failures are still swallowed (the + // contract of these prefetch lambdas is "best-effort, never crash the bulk run"). + private suspend inline fun runCatchingExceptCancel(block: () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.w(TAG, "prefetch block failed: ${e.message}") + } + } + } private fun PrefetchResult?.hasDownloadEvidence(): Boolean = diff --git a/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt index 1775cd9..d217cb1 100644 --- a/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt +++ b/app/src/main/java/io/aatricks/easyreader/work/ChapterDownloadWorker.kt @@ -11,6 +11,8 @@ import dagger.assisted.AssistedInject import io.aatricks.easyreader.data.model.PrefetchMode 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.util.UrlSanitizer /** @@ -25,7 +27,9 @@ import io.aatricks.easyreader.util.UrlSanitizer class ChapterDownloadWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, - private val contentRepository: ContentRepository + private val contentRepository: ContentRepository, + private val libraryRepository: LibraryRepository, + private val downloadStatusReconciler: DownloadStatusReconciler ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { @@ -46,6 +50,9 @@ class ChapterDownloadWorker @AssistedInject constructor( if (existing != null && existing.isComplete && !existing.hasPermanentFailures) { Log.d(TAG, "already complete, skipping worker url=$safeUrl") publishProgress(existing) + // Reconcile so an orphaned isDownloaded=false (e.g. VM was cancelled before its + // own reconcile ran) gets promoted off the worker's durable execution. + runCatching { reconcileFlag(url, existing) } return Result.success(existing.toTerminalData()) } @@ -57,6 +64,10 @@ class ChapterDownloadWorker @AssistedInject constructor( ) { progress -> publishProgress(progress) } publishProgress(result) + // Worker is the durable second writer for the DB flag. Even if the VM call + // that originally enqueued us was cancelled mid-flight, this guarantees the + // flag eventually tracks on-disk reality. + runCatching { reconcileFlag(url, result) } val terminal = result.toTerminalData() // Treat "complete with permanent failures" as success — the loop has nothing more // to do. The badge logic separately downgrades it via hasPermanentFailures. @@ -75,6 +86,11 @@ class ChapterDownloadWorker @AssistedInject constructor( } } + private suspend fun reconcileFlag(url: String, result: PrefetchResult) { + val item = libraryRepository.getItemByUrl(url) ?: return + downloadStatusReconciler.reconcile(item, result, wasUserInspect = true) + } + private suspend fun publishProgress(progress: PrefetchResult) { setProgress( workDataOf( diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/DownloadStatusReconcilerTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/DownloadStatusReconcilerTest.kt new file mode 100644 index 0000000..a6a6b2b --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/DownloadStatusReconcilerTest.kt @@ -0,0 +1,204 @@ +package io.aatricks.easyreader.data.repository + +import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.data.model.PrefetchResult +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DownloadStatusReconcilerTest { + + private val libraryRepository: LibraryRepository = mock() + private lateinit var reconciler: DownloadStatusReconciler + + private val downloadedItem = LibraryItem( + id = "id-downloaded", + title = "Chapter D", + url = "https://example.com/chapter-d", + isDownloaded = true + ) + + private val notDownloadedItem = LibraryItem( + id = "id-not-downloaded", + title = "Chapter N", + url = "https://example.com/chapter-n", + isDownloaded = false + ) + + @Before + fun setup() { + reconciler = DownloadStatusReconciler(libraryRepository) + } + + @Test + fun `promotes when fully downloaded on disk`() = runTest { + val result = PrefetchResult( + url = notDownloadedItem.url, + htmlCached = true, + totalImages = 3, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = false + ) + + reconciler.reconcile(notDownloadedItem, result, wasUserInspect = true) + + verify(libraryRepository).markDownloaded(notDownloadedItem.id, true) + } + + @Test + fun `idempotent when already promoted`() = runTest { + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 3, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = true) + + verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(true)) + verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) + } + + @Test + fun `demotes when permanent failures appear`() = runTest { + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true, + hasPermanentFailures = true + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = true) + + verify(libraryRepository).markDownloaded(downloadedItem.id, false) + } + + @Test + fun `demotes when persistent inspect is terminal but incomplete`() = runTest { + // Transient failures (network) that never recovered — no permanent failures + // recorded, but the chapter is still not fully on disk. Previously this case + // left the flag stuck at true forever. + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = false, + isInProgress = false, + isPersistentDownload = true, + hasPermanentFailures = false + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = true) + + verify(libraryRepository).markDownloaded(downloadedItem.id, false) + } + + @Test + fun `demotes when downloads-tier HTML is missing`() = runTest { + // A persistentOnly inspect that comes back with htmlCached=false yields + // isPersistentDownload=false — i.e. the download HTML was lost between launches. + // The chapter cannot be openable offline so the flag must come down. + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = false, + totalImages = 0, + cachedImages = 0, + isComplete = false, + isInProgress = false, + isPersistentDownload = false + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = true) + + verify(libraryRepository).markDownloaded(downloadedItem.id, false) + } + + @Test + fun `does not demote while inspect is still in progress`() = runTest { + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = false, + isInProgress = true, + isPersistentDownload = true + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = true) + + verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) + } + + @Test + fun `does not demote on non-user inspect`() = runTest { + // A SPECULATIVE / all-tier inspect doesn't authoritatively reflect the downloads + // tier, so it must not knock the flag down. + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 5, + cachedImages = 3, + isComplete = false, + isInProgress = false, + isPersistentDownload = false + ) + + reconciler.reconcile(downloadedItem, result, wasUserInspect = false) + + verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) + } + + @Test + fun `url overload looks up item via repository`() = runTest { + whenever(libraryRepository.getItemByUrl(downloadedItem.url)).thenReturn(downloadedItem) + val result = PrefetchResult( + url = downloadedItem.url, + htmlCached = true, + totalImages = 4, + cachedImages = 4, + isComplete = true, + isPersistentDownload = true + ) + + reconciler.reconcile(downloadedItem.url, result, wasUserInspect = true) + + verify(libraryRepository).getItemByUrl(downloadedItem.url) + } + + @Test + fun `url overload is no-op when item is absent`() = runTest { + whenever(libraryRepository.getItemByUrl("missing")).thenReturn(null) + + reconciler.reconcile( + "missing", + PrefetchResult( + url = "missing", + htmlCached = true, + totalImages = 1, + cachedImages = 1, + isComplete = true, + isPersistentDownload = true + ), + wasUserInspect = true + ) + + verify(libraryRepository, never()).markDownloaded(any(), any()) + } +} + +private inline fun any(): T = org.mockito.kotlin.any() diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index bb7c590..0e97208 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -7,6 +7,7 @@ import io.aatricks.easyreader.data.model.LibraryItem import io.aatricks.easyreader.data.model.PrefetchMode 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.ExploreRepository import io.aatricks.easyreader.data.repository.LibraryRepository import kotlinx.coroutines.Dispatchers @@ -28,6 +29,7 @@ class LibraryViewModelTest { private val libraryRepository: LibraryRepository = mock() private val contentRepository: ContentRepository = mock() private val exploreRepository: ExploreRepository = mock() + private lateinit var reconciler: DownloadStatusReconciler private lateinit var viewModel: LibraryViewModel @@ -44,11 +46,14 @@ class LibraryViewModelTest { whenever(libraryRepository.updateItem(any())).thenReturn(true) } + reconciler = DownloadStatusReconciler(libraryRepository) + viewModel = LibraryViewModel( libraryRepository, contentRepository, exploreRepository, - io.aatricks.easyreader.work.NoOpChapterDownloadQueue() + io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), + reconciler ) } @@ -91,7 +96,7 @@ class LibraryViewModelTest { ) whenever(libraryRepository.libraryItems).thenReturn(libraryItems) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) advanceUntilIdle() activeViewModel.toggleSelection(itemId) @@ -99,7 +104,7 @@ class LibraryViewModelTest { assertEquals(setOf(itemId), activeViewModel.uiState.value.selectedIds) - val restoredViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val restoredViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) advanceUntilIdle() assertTrue(restoredViewModel.uiState.value.selectedIds.isEmpty()) @@ -112,7 +117,7 @@ class LibraryViewModelTest { val item2 = LibraryItem(id = "id-2", title = "Novel 2", url = "https://example.com/novel-2") whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(listOf(item1, item2))) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) advanceUntilIdle() activeViewModel.selectItem(item1.id) @@ -134,7 +139,7 @@ class LibraryViewModelTest { val item2 = LibraryItem(id = "id-2", title = "Novel 2", url = "https://example.com/novel-2") whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(listOf(item1, item2))) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) advanceUntilIdle() activeViewModel.removeItemsImmediate(setOf(item1.id, item2.id)) @@ -164,7 +169,7 @@ class LibraryViewModelTest { @Test fun `toggle source expansion updates collapsed sources and persists`() = runTest { - val vm = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val vm = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) advanceUntilIdle() vm.toggleSourceExpansion("NovelFire") @@ -419,7 +424,7 @@ class LibraryViewModelTest { } @Test - fun `refreshChapterCacheStates does not demote downloaded flag from incomplete inspect`() = runTest { + fun `refreshChapterCacheStates demotes downloaded flag when persistent inspect is incomplete`() = runTest { val chapterUrl = "https://example.com/novel/chapter-13" val downloadedItem = LibraryItem( id = "chapter-13-id", @@ -430,6 +435,10 @@ class LibraryViewModelTest { isDownloaded = true ) val libraryItems = MutableStateFlow(listOf(downloadedItem)) + // No permanent failures recorded, but two images are simply missing from disk. + // The new reconciler treats this as an authoritative terminal signal that the + // chapter is not fully downloaded and demotes the flag so the chapter list + // reflects reality on next render. val inspected = PrefetchResult( url = chapterUrl, htmlCached = true, @@ -443,16 +452,49 @@ class LibraryViewModelTest { whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inspected) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) advanceUntilIdle() verify(contentRepository, timeout(1000)).inspectDownload(chapterUrl) verify(contentRepository, never()).inspectCache(chapterUrl) - verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) + verify(libraryRepository, timeout(1000)).markDownloaded(downloadedItem.id, false) assertEquals(inspected, activeViewModel.uiState.value.chapterCacheStates[chapterUrl]) } + @Test + fun `refreshChapterCacheStates does not demote when persistent inspect is still in progress`() = runTest { + val chapterUrl = "https://example.com/novel/chapter-13b" + val downloadedItem = LibraryItem( + id = "chapter-13b-id", + title = "Chapter 13b", + url = chapterUrl, + currentChapter = "Chapter 13b", + baseTitle = "Novel", + isDownloaded = true + ) + val libraryItems = MutableStateFlow(listOf(downloadedItem)) + val inProgress = PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 4, + cachedImages = 2, + isComplete = false, + isInProgress = true, + isPersistentDownload = true + ) + + whenever(libraryRepository.libraryItems).thenReturn(libraryItems) + whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inProgress) + + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) + activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) + advanceUntilIdle() + + verify(libraryRepository, never()).markDownloaded(eq(downloadedItem.id), eq(false)) + } + @Test fun `addChapters does not mark download when permanent failures are present`() = runTest { val chapter = ChapterInfo("Chapter 14", "https://example.com/novel/chapter-14") @@ -521,7 +563,7 @@ class LibraryViewModelTest { whenever(libraryRepository.getGroupedByTitle(anyOrNull())).thenReturn(mapOf("Novel" to listOf(downloadedItem))) whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn(inspected) - val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue()) + val activeViewModel = LibraryViewModel(libraryRepository, contentRepository, exploreRepository, io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), reconciler) activeViewModel.refreshChapterCacheStates(listOf(chapterUrl)) advanceUntilIdle() diff --git a/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt b/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt index 0c56b76..a8b4254 100644 --- a/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/work/ChapterDownloadWorkerTest.kt @@ -16,6 +16,8 @@ import androidx.work.workDataOf import io.aatricks.easyreader.data.model.PrefetchMode 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 kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -135,6 +137,80 @@ class ChapterDownloadWorkerTest { assertTrue("expected Retry, got $result", result is ListenableWorker.Result.Retry) } + @Test + fun `worker promotes DB flag after successful prefetch when item not yet downloaded`(): Unit = runBlocking { + val chapterUrl = "https://example.com/work-promote" + val contentRepository = mock() + val libraryRepository = mock() + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn( + PrefetchResult(url = chapterUrl, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false) + ) + whenever(contentRepository.prefetchWithProgress(eq(chapterUrl), eq(PrefetchMode.USER_REQUESTED), any())) + .thenReturn( + PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 3, + cachedImages = 3, + isComplete = true, + isPersistentDownload = true + ) + ) + whenever(libraryRepository.getItemByUrl(chapterUrl)).thenReturn( + io.aatricks.easyreader.data.model.LibraryItem( + id = "lib-id", + title = "Chapter promote", + url = chapterUrl, + isDownloaded = false + ) + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to chapterUrl)) + .setWorkerFactory(workerFactoryWith(contentRepository, libraryRepository)) + .build() + + val result = worker.doWork() + assertTrue("expected Success, got $result", result is ListenableWorker.Result.Success) + verify(libraryRepository).markDownloaded("lib-id", true) + } + + @Test + fun `worker short-circuit still reconciles flag for orphaned downloads`(): Unit = runBlocking { + val chapterUrl = "https://example.com/work-shortcircuit-promote" + val contentRepository = mock() + val libraryRepository = mock() + // Inspect short-circuits because in-process call already finished, but the VM was + // cancelled before it could write the flag. Worker must still write it. + whenever(contentRepository.inspectDownload(chapterUrl)).thenReturn( + PrefetchResult( + url = chapterUrl, + htmlCached = true, + totalImages = 4, + cachedImages = 4, + isComplete = true, + isPersistentDownload = true + ) + ) + whenever(libraryRepository.getItemByUrl(chapterUrl)).thenReturn( + io.aatricks.easyreader.data.model.LibraryItem( + id = "lib-orphan", + title = "Chapter orphan", + url = chapterUrl, + isDownloaded = false + ) + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(workDataOf(ChapterDownloadWorker.KEY_CHAPTER_URL to chapterUrl)) + .setWorkerFactory(workerFactoryWith(contentRepository, libraryRepository)) + .build() + + val result = worker.doWork() + assertTrue("expected Success, got $result", result is ListenableWorker.Result.Success) + verify(libraryRepository).markDownloaded("lib-orphan", true) + } + @Test fun `queue enqueueUniqueWork dedupes by chapter url`() = runBlocking { val queue = WorkManagerChapterDownloadQueue(context) @@ -182,7 +258,11 @@ class ChapterDownloadWorkerTest { assertTrue("expected at most one live work, got $infos", live <= 1) } - private fun workerFactoryWith(contentRepository: ContentRepository): androidx.work.WorkerFactory { + private fun workerFactoryWith( + contentRepository: ContentRepository, + libraryRepository: LibraryRepository = mock(), + reconciler: DownloadStatusReconciler = DownloadStatusReconciler(libraryRepository) + ): androidx.work.WorkerFactory { return object : androidx.work.WorkerFactory() { override fun createWorker( appContext: Context, @@ -190,7 +270,13 @@ class ChapterDownloadWorkerTest { workerParameters: WorkerParameters ): ListenableWorker? { return if (workerClassName == ChapterDownloadWorker::class.java.name) { - ChapterDownloadWorker(appContext, workerParameters, contentRepository) + ChapterDownloadWorker( + appContext, + workerParameters, + contentRepository, + libraryRepository, + reconciler + ) } else null } } From 0c8a8634f9a4246f97bd14b3a68e302f0f3dc503 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Thu, 21 May 2026 11:47:06 -0400 Subject: [PATCH 07/19] Add image integrity checks and short-read handling Add ImageIntegrity to detect HTML, zero-byte, and truncated files. Use it in ImageCache and WebContentLoader to avoid accepting corrupt fallbacks. Make ImageDownloader verify Content-Length against bytes read and fail on short reads so partial responses don't enter the cache and trigger retry paths. Update tests and fixtures accordingly. --- .../content/HttpMediaCacheFetcher.kt | 33 +---- .../data/repository/content/ImageCache.kt | 2 +- .../repository/content/ImageDownloader.kt | 12 +- .../repository/content/WebContentLoader.kt | 16 ++- .../easyreader/util/ImageIntegrity.kt | 135 +++++++++++++++--- .../data/repository/content/ImageCacheTest.kt | 15 +- .../content/WebContentLoaderTest.kt | 18 ++- .../easyreader/util/ImageIntegrityTest.kt | 82 ++++++++++- 8 files changed, 242 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/HttpMediaCacheFetcher.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/HttpMediaCacheFetcher.kt index da7554b..d0aa9e2 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/HttpMediaCacheFetcher.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/HttpMediaCacheFetcher.kt @@ -10,6 +10,7 @@ import coil3.fetch.SourceFetchResult import coil3.network.httpHeaders import coil3.request.Options import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.util.ImageIntegrity import okio.Path.Companion.toPath import java.io.File @@ -20,9 +21,6 @@ class HttpMediaCacheFetcher( private val contentRepository: ContentRepository, private val options: Options ) : Fetcher { - private companion object { - const val HTML_SIGNATURE_SNIFF_BYTES = 512 - } override suspend fun fetch(): FetchResult? { val pageUrl = options.extras[ChapterPageUrlExtra]?.takeIf { it.isNotBlank() } @@ -71,28 +69,9 @@ class HttpMediaCacheFetcher( } } - private fun File.isUsableCachedMedia(): Boolean { - if (!exists() || length() <= 0L) return false - return !isLikelyHtmlPayload() - } - - private fun File.isLikelyHtmlPayload(): Boolean { - return runCatching { - inputStream().use { stream -> - val bytes = ByteArray(HTML_SIGNATURE_SNIFF_BYTES) - val read = stream.read(bytes) - if (read <= 0) return@runCatching false - val prefix = bytes.decodeToString(endIndex = read) - .trimStart() - .lowercase() - when { - prefix.startsWith(" false - prefix.startsWith(" true - prefix.startsWith(" true - prefix.startsWith("<") && prefix.contains("cloudflare") -> true - else -> false - } - } - }.getOrDefault(false) - } + // Reuse the same integrity rule the inspect path uses so a file inspect counts as + // "downloaded" is also a file the fetcher will serve from disk. If the cached file is + // truncated or an HTML challenge, return false here and the caller redownloads instead + // of handing Coil a broken file that would surface as "Image unavailable". + private fun File.isUsableCachedMedia(): Boolean = ImageIntegrity.isValidImageFile(this) } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt index 8daeeff..95acbfb 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageCache.kt @@ -29,7 +29,7 @@ class ImageCache @Inject constructor( fun trimToSize(maxBytes: Long): Long = FileSizeUtils.trimDirectoryToSize(mediaCacheDir, maxBytes) fun findExistingCachedMediaFile(url: String): File? = - candidateFiles(url).firstOrNull(File::exists) + candidateFiles(url).firstOrNull { it.exists() && it.isCachedImageValid() } fun destinationFile(url: String, tier: StorageTier): File { val key = CacheKeyUtils.keyFor(url) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageDownloader.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageDownloader.kt index b399607..6954a9a 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageDownloader.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/ImageDownloader.kt @@ -204,9 +204,9 @@ class ImageDownloader @Inject constructor( return if (destinationFile != null) { try { + var totalRead = 0L destinationFile.sink().buffer().use { sink -> val source = body.source() - var totalRead = 0L while (true) { val read = source.read(sink.buffer, 8192) if (read == -1L) break @@ -217,6 +217,16 @@ class ImageDownloader @Inject constructor( sink.emitCompleteSegments() } } + // Some servers close the connection mid-stream without throwing — OkHttp + // returns -1 cleanly and we end up persisting a truncated body. If the + // response advertised a Content-Length, fail loud when the bytes we got + // don't match so the retry path runs and the disk file never enters the + // cache in a half-baked state. + if (contentLength != -1L && totalRead != contentLength) { + return ImageFetchResult.NetworkError( + IOException("Short read: got $totalRead, expected $contentLength") + ) + } ImageFetchResult.Success(destinationFile) } catch (e: Exception) { ImageFetchResult.NetworkError(e as? IOException ?: IOException(e)) 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 d8b128e..1647218 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 @@ -999,8 +999,20 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( is ImageFetchResult.Success -> { val finalFile = when { tempFile.renameTo(cachedFile) -> cachedFile - cachedFile.exists() -> { tempFile.delete(); cachedFile } - else -> null + // Fallback: rename failed but a file already sits at the + // target path. Only trust it if it actually decodes; a stale + // truncated/HTML file from a pre-fix download would otherwise + // get cemented in place and the fresh tempFile thrown away. + cachedFile.exists() && imageCache.isValidImageFile(cachedFile) -> { + tempFile.delete() + cachedFile + } + else -> { + // Either nothing at target, or what's there is corrupt. + // Force the freshly-downloaded tempFile into place. + cachedFile.delete() + if (tempFile.renameTo(cachedFile)) cachedFile else null + } } if (finalFile == null) { tempFile.delete() diff --git a/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt index aa95986..607badd 100644 --- a/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt +++ b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt @@ -1,11 +1,21 @@ package io.aatricks.easyreader.util import java.io.File +import java.io.RandomAccessFile /** - * Cheap integrity check for cached image files. Catches truncated downloads, zero-byte files, - * and HTML error pages (Cloudflare/CDN challenges) returned with an image content-type that - * `File.exists()` alone would treat as a successful download. + * Integrity check for cached image files. Catches three failure modes that + * `File.exists()` alone would miss: + * 1. Zero-byte / single-byte files. + * 2. HTML error pages (Cloudflare/CDN challenges) returned with an image content-type. + * 3. Truncated downloads — a file with the correct magic header but missing trailer. + * Coil's decoder rejects these at read time and the user sees "Image unavailable" + * while the chapter badge still says Downloaded. The structural trailer check below + * keeps inspect honest with what the reader can actually decode. + * + * Trailer check covers JPEG/PNG/WebP — the formats real manga sources use. GIF/BMP/AVIF/SVG + * fall back to the magic-byte-only check; if we encounter widespread truncation there we + * add their trailers too. */ object ImageIntegrity { // Conservative lower bound — only rejects obviously-truncated downloads (zero bytes, @@ -14,54 +24,66 @@ object ImageIntegrity { // alone is 8 bytes, JPEG SOI is 2 bytes, WebP needs 12 bytes for the RIFF+WEBP brand. private const val MIN_VALID_IMAGE_BYTES = 16L private const val SNIFF_BYTES = 32 + // Real CDN-served images often append metadata, watermarks, or padding after the + // format's spec-required end marker. Search this many bytes back from EOF so we accept + // valid images that aren't bit-for-bit spec-pure while still catching truncations + // (which omit the marker entirely). + private const val TRAILER_SCAN_BYTES = 512 fun isValidImageFile(file: File): Boolean { if (!file.exists() || file.length() < MIN_VALID_IMAGE_BYTES) return false val header = readHeader(file) ?: return false - return classify(header) == ImageKind.Image + val kind = classifyFormat(header) ?: return false + return when (kind) { + ImageFormat.JPEG -> jpegLooksComplete(file) + ImageFormat.PNG -> pngLooksComplete(file) + ImageFormat.WEBP -> webpLooksComplete(file) + // No trailer check defined; magic check alone — we accept these because the + // download path is harder to truncate transparently and these formats are rare + // in real chapter content. + ImageFormat.GIF, + ImageFormat.BMP, + ImageFormat.AVIF_HEIF, + ImageFormat.SVG -> true + } } - private enum class ImageKind { Image, Html, Unknown } - - private fun classify(header: ByteArray): ImageKind { - if (looksLikeImage(header)) return ImageKind.Image - if (looksLikeHtml(header)) return ImageKind.Html - return ImageKind.Unknown - } + private enum class ImageFormat { JPEG, PNG, GIF, WEBP, BMP, AVIF_HEIF, SVG } - private fun looksLikeImage(header: ByteArray): Boolean { - if (header.size < 4) return false + private fun classifyFormat(header: ByteArray): ImageFormat? { + if (header.size < 4) return null + if (looksLikeHtml(header)) return null // JPEG: FF D8 FF - if (header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() && header[2] == 0xFF.toByte()) return true + if (header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte() && header[2] == 0xFF.toByte()) return ImageFormat.JPEG // PNG: 89 50 4E 47 0D 0A 1A 0A if (header.size >= 8 && header[0] == 0x89.toByte() && header[1] == 0x50.toByte() && header[2] == 0x4E.toByte() && header[3] == 0x47.toByte() && header[4] == 0x0D.toByte() && header[5] == 0x0A.toByte() && - header[6] == 0x1A.toByte() && header[7] == 0x0A.toByte()) return true + header[6] == 0x1A.toByte() && header[7] == 0x0A.toByte()) return ImageFormat.PNG // GIF87a / GIF89a if (header.size >= 6 && header[0] == 'G'.code.toByte() && header[1] == 'I'.code.toByte() && header[2] == 'F'.code.toByte() && header[3] == '8'.code.toByte() && (header[4] == '7'.code.toByte() || header[4] == '9'.code.toByte()) && - header[5] == 'a'.code.toByte()) return true + header[5] == 'a'.code.toByte()) return ImageFormat.GIF // WebP: "RIFF" .... "WEBP" if (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()) return true + header[10] == 'B'.code.toByte() && header[11] == 'P'.code.toByte()) return ImageFormat.WEBP // BMP: "BM" - if (header[0] == 'B'.code.toByte() && header[1] == 'M'.code.toByte()) return true + if (header[0] == 'B'.code.toByte() && header[1] == 'M'.code.toByte()) return ImageFormat.BMP // AVIF / HEIF: ftyp box at offset 4 (skip 4-byte size), then "ftyp" + brand if (header.size >= 12 && header[4] == 'f'.code.toByte() && header[5] == 't'.code.toByte() && - header[6] == 'y'.code.toByte() && header[7] == 'p'.code.toByte()) return true + header[6] == 'y'.code.toByte() && header[7] == 'p'.code.toByte()) return ImageFormat.AVIF_HEIF // SVG: starts with "= 0 + } + + // PNG IEND chunk header: 4-byte length (always 0) + 4-byte type "IEND". Scan trailing + // window — same rationale as JPEG: trailing bytes after IEND occur in the wild. + private val pngIendHeader = byteArrayOf( + 0x00, 0x00, 0x00, 0x00, + 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte() + ) + private fun pngLooksComplete(file: File): Boolean { + val tail = readTrailer(file, TRAILER_SCAN_BYTES) ?: return false + return indexOfLast(tail, pngIendHeader) >= 0 + } + + private fun indexOfLast(haystack: ByteArray, needle: ByteArray): Int { + if (needle.isEmpty() || haystack.size < needle.size) return -1 + outer@ for (start in (haystack.size - needle.size) downTo 0) { + for (i in needle.indices) { + if (haystack[start + i] != needle[i]) continue@outer + } + return start + } + return -1 + } + + // WebP RIFF header declares size of (file - 8). If the file is shorter than that, it + // was cut off; longer is OK because chunks can be padded. Header layout: + // bytes 0..3 = "RIFF" + // bytes 4..7 = little-endian uint32 chunk size (covers bytes 8..end) + // bytes 8..11 = "WEBP" + private fun webpLooksComplete(file: File): Boolean { + val header = runCatching { + file.inputStream().use { stream -> + val bytes = ByteArray(12) + val read = stream.read(bytes) + if (read == 12) bytes else null + } + }.getOrNull() ?: return false + val declared = (header[4].toInt() and 0xFF) or + ((header[5].toInt() and 0xFF) shl 8) or + ((header[6].toInt() and 0xFF) shl 16) or + ((header[7].toInt() and 0xFF) shl 24) + // declared is the size from offset 8 onward; total file size therefore is declared + 8. + return file.length() >= declared.toLong() + 8L + } + private fun readHeader(file: File): ByteArray? = runCatching { file.inputStream().use { stream -> val bytes = ByteArray(SNIFF_BYTES) @@ -80,4 +154,21 @@ object ImageIntegrity { if (read <= 0) null else bytes.copyOf(read) } }.getOrNull() + + private fun readTrailer(file: File, byteCount: Int): ByteArray? = runCatching { + val length = file.length() + val want = byteCount.toLong().coerceAtMost(length).toInt() + if (want <= 0) return@runCatching null + RandomAccessFile(file, "r").use { raf -> + raf.seek(length - want) + val bytes = ByteArray(want) + var read = 0 + while (read < want) { + val n = raf.read(bytes, read, want - read) + if (n == -1) return@runCatching null + read += n + } + bytes + } + }.getOrNull() } diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt index fefd18f..2a4df2b 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/ImageCacheTest.kt @@ -39,8 +39,8 @@ class ImageCacheTest { fun `getCachedMediaFile returns existing legacy file if primary does not exist`() { val url = "https://example.com/image.jpg" val legacyFile = File(cacheDir, url.hashCode().toString()) - legacyFile.writeText("legacy content") - + legacyFile.writeBytes(validJpegBytes()) + val actual = imageCache.getCachedMediaFile(url) assertEquals(legacyFile.absolutePath, actual.absolutePath) } @@ -50,13 +50,18 @@ class ImageCacheTest { val url = "https://example.com/image.jpg" val primaryFile = File(cacheDir, CacheKeyUtils.keyFor(url)) val legacyFile = File(cacheDir, url.hashCode().toString()) - primaryFile.writeText("primary content") - legacyFile.writeText("legacy content") - + primaryFile.writeBytes(validJpegBytes()) + legacyFile.writeBytes(validJpegBytes()) + val actual = imageCache.getCachedMediaFile(url) assertEquals(primaryFile.absolutePath, actual.absolutePath) } + private fun validJpegBytes(): ByteArray = + byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + ByteArray(60) + + byteArrayOf(0xFF.toByte(), 0xD9.toByte()) + @Test fun `deleteCachedMediaFiles deletes both primary and legacy files`() { val url = "https://example.com/image.jpg" diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt index 62ada6d..4ea03ee 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt @@ -707,9 +707,13 @@ class WebContentLoaderTest { code: Int = 200 ): Response { val payload = if (code == 200 && contentType.startsWith("image/")) { - val bytes = VALID_JPEG_HEADER + body.toByteArray() - // ImageIntegrity requires at least 64 bytes; pad with zeros if the fixture body is short. - if (bytes.size < 64) bytes + ByteArray(64 - bytes.size) else bytes + val bodyBytes = body.toByteArray() + // ImageIntegrity now requires a structural trailer (EOI marker for JPEG) so the + // fixture must look like a complete file end-to-end. Pad to ≥64 bytes and finish + // with FF D9 so inspect treats the body as a valid (if minimal) JPEG. + val header = VALID_JPEG_HEADER + bodyBytes + val padded = if (header.size < 62) header + ByteArray(62 - header.size) else header + padded + JPEG_EOI } else { body.toByteArray() } @@ -723,10 +727,12 @@ class WebContentLoaderTest { } private companion object { - // ImageIntegrity validates downloaded files by magic bytes. Test fixtures use string - // bodies like "image-body" that wouldn't pass; prepend a JPEG SOI/APP0 marker so the - // integrity check accepts them without requiring real image payloads. + // ImageIntegrity validates downloaded files by magic bytes AND structural trailer. + // Test fixtures use string bodies like "image-body" that wouldn't pass; wrap them + // in a JPEG SOI/APP0 header and EOI trailer so the integrity check accepts them + // without requiring real image payloads. private val VALID_JPEG_HEADER = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + private val JPEG_EOI = byteArrayOf(0xFF.toByte(), 0xD9.toByte()) } private fun buildByteResponse( diff --git a/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt index 56a1989..2f8b43f 100644 --- a/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt @@ -23,32 +23,100 @@ class ImageIntegrityTest { } @Test - fun `accepts JPEG with SOI marker`() { - val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + ByteArray(64) + fun `accepts JPEG with SOI header and EOI trailer`() { + val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + ByteArray(60) + + byteArrayOf(0xFF.toByte(), 0xD9.toByte()) val file = tempFolder.newFile("ok.jpg").apply { writeBytes(payload) } assertTrue(ImageIntegrity.isValidImageFile(file)) } @Test - fun `accepts PNG with full 8-byte signature`() { + fun `accepts JPEG with trailing metadata after EOI`() { + // Real CDN-served JPEGs frequently append EXIF tails, watermarks, anti-scrape + // padding etc. after the spec-required EOI. The integrity check must accept these. + val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + ByteArray(60) + + byteArrayOf(0xFF.toByte(), 0xD9.toByte()) + + "trailing watermark".toByteArray() + val file = tempFolder.newFile("trailing.jpg").apply { writeBytes(payload) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts PNG with trailing metadata after IEND`() { val signature = byteArrayOf( 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ) - val file = tempFolder.newFile("ok.png").apply { writeBytes(signature + ByteArray(32)) } + val iend = byteArrayOf( + 0x00, 0x00, 0x00, 0x00, + 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte(), + 0xAE.toByte(), 0x42.toByte(), 0x60.toByte(), 0x82.toByte() + ) + val file = tempFolder.newFile("trailing.png").apply { + writeBytes(signature + ByteArray(32) + iend + "extra-junk".toByteArray()) + } assertTrue(ImageIntegrity.isValidImageFile(file)) } @Test - fun `accepts WebP with RIFF and WEBP brand`() { + fun `rejects JPEG missing EOI trailer (truncated mid-image)`() { + // Truncated download: header parses fine, but body is cut and EOI never written. + // Previously this passed the magic-byte check and inspect counted it as Downloaded. + val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + ByteArray(60) + val file = tempFolder.newFile("truncated.jpg").apply { writeBytes(payload) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts PNG with IEND chunk`() { + val signature = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ) + // IEND chunk: 4-byte length=0, 4-byte type "IEND", 4-byte CRC (any value). + val iend = byteArrayOf( + 0x00, 0x00, 0x00, 0x00, + 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte(), + 0xAE.toByte(), 0x42.toByte(), 0x60.toByte(), 0x82.toByte() + ) + val file = tempFolder.newFile("ok.png").apply { writeBytes(signature + ByteArray(32) + iend) } + assertTrue(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `rejects PNG missing IEND chunk (truncated)`() { + val signature = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + ) + val file = tempFolder.newFile("truncated.png").apply { writeBytes(signature + ByteArray(32)) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + + @Test + fun `accepts WebP with declared chunk size matching file length`() { + // RIFF declared size = 24 means total file size = 32. Pad payload to 32 bytes total. val webp = byteArrayOf( 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), - 0, 0, 0, 0, + 24, 0, 0, 0, 'W'.code.toByte(), 'E'.code.toByte(), 'B'.code.toByte(), 'P'.code.toByte() - ) + ByteArray(16) + ) + ByteArray(20) val file = tempFolder.newFile("ok.webp").apply { writeBytes(webp) } assertTrue(ImageIntegrity.isValidImageFile(file)) } + @Test + fun `rejects WebP truncated below declared chunk size`() { + // RIFF claims 200 bytes of payload but file only carries 20 — truncated. + val webp = byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + 200.toByte(), 0, 0, 0, + 'W'.code.toByte(), 'E'.code.toByte(), 'B'.code.toByte(), 'P'.code.toByte() + ) + ByteArray(20) + val file = tempFolder.newFile("truncated.webp").apply { writeBytes(webp) } + assertFalse(ImageIntegrity.isValidImageFile(file)) + } + @Test fun `rejects HTML payload masquerading as image`() { val html = "Cloudflare challenge".toByteArray() From f71946a75f5da22510c6cc7179e72dddde0d4240 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Thu, 21 May 2026 23:01:18 -0400 Subject: [PATCH 08/19] Add image dimension cache and Room migration Also relax JPEG EOF integrity check and add reader drag/restore flags; update tests and bump DB schema to v9 --- .../9.json | 322 ++++++++++++++++++ .../easyreader/EasyReaderApplication.kt | 10 + .../easyreader/data/local/AppDatabase.kt | 20 +- .../data/local/ImageDimensionDao.kt | 28 ++ .../data/model/ImageDimensionEntity.kt | 13 + .../easyreader/data/repository/HtmlParser.kt | 16 +- .../ImageDimensionCacheRepository.kt | 66 ++++ .../repository/content/EpubContentLoader.kt | 46 ++- .../repository/content/WebContentLoader.kt | 29 +- .../aatricks/easyreader/di/DatabaseModule.kt | 10 +- .../ui/screens/reader/ReaderContentArea.kt | 110 ++++-- .../screens/reader/ReaderContentRenderers.kt | 18 + .../ui/viewmodel/ReaderProgressController.kt | 77 ++++- .../ui/viewmodel/ReaderViewModel.kt | 29 +- .../easyreader/util/ImageIntegrity.kt | 103 +----- .../data/local/AppDatabaseMigrationTest.kt | 5 +- .../repository/ContentRepositoryEpubTest.kt | 6 +- .../WebContentLoaderPrefetchRetryTest.kt | 3 +- .../content/WebContentLoaderTest.kt | 14 +- .../testutil/FakeImageDimensionCache.kt | 33 ++ .../ReaderViewModelNavigationTest.kt | 4 +- .../viewmodel/ReaderViewModelSecurityTest.kt | 4 +- .../ui/viewmodel/ReaderViewModelTest.kt | 15 +- .../easyreader/util/ImageIntegrityTest.kt | 19 +- 24 files changed, 841 insertions(+), 159 deletions(-) create mode 100644 app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json create mode 100644 app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt create mode 100644 app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/testutil/FakeImageDimensionCache.kt diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json new file mode 100644 index 0000000..6b81e97 --- /dev/null +++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/9.json @@ -0,0 +1,322 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "418d090f12fe7a637c75bdd200954d84", + "entities": [ + { + "tableName": "library_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadElementKey` TEXT NOT NULL, `lastReadOffsetFraction` REAL NOT NULL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCurrentlyReading", + "columnName": "isCurrentlyReading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentChapter", + "columnName": "currentChapter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentChapterUrl", + "columnName": "currentChapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalChapters", + "columnName": "totalChapters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "dateAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloading", + "columnName": "isDownloading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrollPosition", + "columnName": "lastScrollPosition", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lastReadIndex", + "columnName": "lastReadIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadElementKey", + "columnName": "lastReadElementKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastReadOffsetFraction", + "columnName": "lastReadOffsetFraction", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hasUpdates", + "columnName": "hasUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterSummaries", + "columnName": "chapterSummaries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseTitle", + "columnName": "baseTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readingMode", + "columnName": "readingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseNovelUrl", + "columnName": "baseNovelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_library_items_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_library_items_baseTitle", + "unique": false, + "columnNames": [ + "baseTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)" + }, + { + "name": "index_library_items_isCurrentlyReading", + "unique": false, + "columnNames": [ + "isCurrentlyReading" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)" + }, + { + "name": "index_library_items_lastRead", + "unique": false, + "columnNames": [ + "lastRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)" + } + ] + }, + { + "tableName": "chapter_image_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))", + "fields": [ + { + "fieldPath": "chapterUrl", + "columnName": "chapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptMs", + "columnName": "lastAttemptMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "httpStatusCode", + "columnName": "httpStatusCode", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chapterUrl", + "imageUrl" + ] + }, + "indices": [ + { + "name": "index_chapter_image_state_chapterUrl", + "unique": false, + "columnNames": [ + "chapterUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)" + }, + { + "name": "index_chapter_image_state_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)" + } + ] + }, + { + "tableName": "image_dimension_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`imageUrl` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `cachedAtMs` INTEGER NOT NULL, `parserVersion` INTEGER NOT NULL, PRIMARY KEY(`imageUrl`))", + "fields": [ + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parserVersion", + "columnName": "parserVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "imageUrl" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '418d090f12fe7a637c75bdd200954d84')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt index 0766383..9348a0d 100644 --- a/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt +++ b/app/src/main/java/io/aatricks/easyreader/EasyReaderApplication.kt @@ -12,6 +12,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.crossfade import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.repository.ContentRepository +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.data.repository.content.EpubImageFetcher import io.aatricks.easyreader.data.repository.content.HttpMediaCacheFetcher import io.aatricks.easyreader.util.CrashRecorder @@ -30,6 +31,7 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Confi @Inject lateinit var contentRepository: ContentRepository @Inject lateinit var preferencesManager: PreferencesManager @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject lateinit var imageDimensionCache: ImageDimensionCacheRepository // WorkManager pulls this lazily before its first enqueue, which happens after Hilt // injection has populated `workerFactory`. Using on-demand initialization (no manual @@ -46,6 +48,14 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory, Confi super.onCreate() CrashRecorder.install(this) prewarmLastReadChapter() + pruneImageDimensionCache() + } + + private fun pruneImageDimensionCache() { + warmupScope.launch { + runCatching { imageDimensionCache.prune() } + .onFailure { Log.w(TAG, "image dim cache prune failed message=${it.message}") } + } } // Kick off chapter parse on a background coroutine so it overlaps Hilt graph build, diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt index 623536a..9065f35 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt @@ -6,17 +6,19 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import io.aatricks.easyreader.data.model.ChapterImageStateEntity +import io.aatricks.easyreader.data.model.ImageDimensionEntity import io.aatricks.easyreader.data.model.LibraryItem @Database( - entities = [LibraryItem::class, ChapterImageStateEntity::class], - version = 8, + entities = [LibraryItem::class, ChapterImageStateEntity::class, ImageDimensionEntity::class], + version = 9, exportSchema = true ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao abstract fun chapterImageStateDao(): ChapterImageStateDao + abstract fun imageDimensionDao(): ImageDimensionDao companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -283,5 +285,19 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("CREATE INDEX index_library_items_lastRead ON library_items (lastRead)") } } + + val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS image_dimension_cache ( + imageUrl TEXT NOT NULL PRIMARY KEY, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + cachedAtMs INTEGER NOT NULL, + parserVersion INTEGER NOT NULL + ) + """.trimIndent()) + } + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt new file mode 100644 index 0000000..4a0692b --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/local/ImageDimensionDao.kt @@ -0,0 +1,28 @@ +package io.aatricks.easyreader.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.aatricks.easyreader.data.model.ImageDimensionEntity + +@Dao +interface ImageDimensionDao { + @Query(""" + SELECT * FROM image_dimension_cache + WHERE imageUrl IN (:urls) AND parserVersion = :parserVersion + """) + suspend fun getMany(urls: List, parserVersion: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: ImageDimensionEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(entities: List) + + @Query(""" + DELETE FROM image_dimension_cache + WHERE cachedAtMs < :cutoffMs OR parserVersion < :currentParserVersion + """) + suspend fun prune(cutoffMs: Long, currentParserVersion: Int) +} diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt new file mode 100644 index 0000000..158338f --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/model/ImageDimensionEntity.kt @@ -0,0 +1,13 @@ +package io.aatricks.easyreader.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "image_dimension_cache") +data class ImageDimensionEntity( + @PrimaryKey val imageUrl: String, + val width: Int, + val height: Int, + val cachedAtMs: Long, + val parserVersion: Int +) 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 c753f43..a5dedb6 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 @@ -55,8 +55,12 @@ class HtmlParser @Inject constructor() { val filteredParagraphs = filterParagraphs(paragraphs, document.title()) - // If it looks like a manga (many images or few paragraphs), return images - if (images.size > 5 || (images.isNotEmpty() && filteredParagraphs.size < 10)) { + // Chapter pages from manga sites legitimately have many paragraphs of footer text + // (comments, ads, "if you want to read free manga..."). If any chapter image was + // extracted from a manga reader container, trust that this is image content and + // ignore the boilerplate paragraphs — otherwise the user sees the footer text and + // none of the actual manga pages. + if (images.isNotEmpty() && (images.size > 5 || isChapterPage(url) || filteredParagraphs.size < 10)) { return images } @@ -67,6 +71,14 @@ class HtmlParser @Inject constructor() { return mergeAndFormatParagraphs(filteredParagraphs) } + private fun isChapterPage(url: String): Boolean { + val lower = url.lowercase() + return lower.contains("/chapter/") || + lower.contains("/chapter-") || + lower.contains("-chapter-") || + lower.contains("/manga/") && lower.contains("chapter") + } + private fun cleanDocument(document: Document): Unit { // Remove advertisements val adSelectors = listOf( diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt new file mode 100644 index 0000000..eaa702c --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ImageDimensionCacheRepository.kt @@ -0,0 +1,66 @@ +package io.aatricks.easyreader.data.repository + +import io.aatricks.easyreader.data.local.ImageDimensionDao +import io.aatricks.easyreader.data.model.ImageDimensionEntity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ImageDimensionCacheRepository @Inject constructor( + private val dao: ImageDimensionDao +) { + companion object { + /** + * Bump when the upstream dimension-resolution code shape changes (e.g. parser swap, + * sniff format change). Stale rows are filtered out on read and pruned at app start. + */ + const val CURRENT_PARSER_VERSION: Int = 1 + + const val DEFAULT_TTL_MS: Long = 90L * 24L * 60L * 60L * 1000L // 90 days + } + + suspend fun getMany(urls: List): Map { + if (urls.isEmpty()) return emptyMap() + return runCatching { + dao.getMany(urls.distinct(), CURRENT_PARSER_VERSION).associateBy { it.imageUrl } + }.getOrElse { emptyMap() } + } + + suspend fun persist(imageUrl: String, width: Int, height: Int) { + if (imageUrl.isBlank() || width <= 0 || height <= 0) return + runCatching { + dao.upsert( + ImageDimensionEntity( + imageUrl = imageUrl, + width = width, + height = height, + cachedAtMs = System.currentTimeMillis(), + parserVersion = CURRENT_PARSER_VERSION + ) + ) + } + } + + suspend fun persistAll(entries: List>) { + if (entries.isEmpty()) return + val now = System.currentTimeMillis() + val rows = entries + .filter { it.first.isNotBlank() && it.second > 0 && it.third > 0 } + .map { (url, w, h) -> + ImageDimensionEntity( + imageUrl = url, + width = w, + height = h, + cachedAtMs = now, + parserVersion = CURRENT_PARSER_VERSION + ) + } + if (rows.isEmpty()) return + runCatching { dao.upsertAll(rows) } + } + + suspend fun prune(ttlMs: Long = DEFAULT_TTL_MS) { + val cutoff = System.currentTimeMillis() - ttlMs + runCatching { dao.prune(cutoff, CURRENT_PARSER_VERSION) } + } +} diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/content/EpubContentLoader.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/content/EpubContentLoader.kt index 7da9ec8..b8d0eb6 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/content/EpubContentLoader.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/content/EpubContentLoader.kt @@ -14,6 +14,7 @@ import java.io.File import java.util.UUID import java.util.zip.ZipFile import dagger.hilt.android.qualifiers.ApplicationContext +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.di.EpubCacheDir import io.aatricks.easyreader.di.EpubDownloadsDir import javax.inject.Inject @@ -23,7 +24,8 @@ import javax.inject.Singleton class EpubContentLoader @Inject constructor( @ApplicationContext private val context: Context, @EpubCacheDir private val epubCacheDir: File, - @EpubDownloadsDir private val epubDownloadsDir: File + @EpubDownloadsDir private val epubDownloadsDir: File, + private val imageDimensionCache: ImageDimensionCacheRepository ) { companion object { private val WHITESPACE_REGEX = Regex("\\s+") @@ -365,7 +367,7 @@ class EpubContentLoader @Inject constructor( ) } - private fun loadEpubChapter(filePath: String, book: EpubBook, href: String): EpubChapter { + private suspend fun loadEpubChapter(filePath: String, book: EpubBook, href: String): EpubChapter { val file = resolveEpubFile(filePath) val chapterHref = normalizeEpubPath(href.substringBefore("#").replace("\\", "/").removePrefix("/")) val chapterHrefs = getChapterSpineHrefs(book, chapterHref) @@ -387,15 +389,53 @@ class EpubContentLoader @Inject constructor( if (!loadedAnyDocument) throw Exception("No chapter bytes") + val enrichedEls = enrichEpubImageDimensionsFromCache(els) + return EpubChapter( href = chapterHref, title = book.findTocItemByHref(chapterHref)?.title, - content = els, + content = enrichedEls, nextHref = book.getNextHref(chapterHref), previousHref = book.getPreviousHref(chapterHref) ) } + private suspend fun enrichEpubImageDimensionsFromCache( + els: List + ): List { + val imageUrls = mutableListOf() + els.forEach { el -> + when (el) { + is ContentElement.Image -> + if (el.width <= 0 || el.height <= 0) imageUrls.add(el.url) + is ContentElement.ImageGroup -> + el.images.forEach { img -> + if (img.width <= 0 || img.height <= 0) imageUrls.add(img.url) + } + else -> Unit + } + } + if (imageUrls.isEmpty()) return els + val cached = imageDimensionCache.getMany(imageUrls) + if (cached.isEmpty()) return els + + return els.map { el -> + when (el) { + is ContentElement.Image -> { + if (el.width > 0 && el.height > 0) el + else cached[el.url]?.let { el.copy(width = it.width, height = it.height) } ?: el + } + is ContentElement.ImageGroup -> el.copy( + images = el.images.map { img -> + if (img.width > 0 && img.height > 0) img + else cached[img.url]?.let { img.copy(width = it.width, height = it.height) } ?: img + } + ) + else -> el + } + } + } + private fun getChapterSpineHrefs(book: EpubBook, chapterHref: String): List { val startIndex = book.spine.indexOfFirst { epubPathsMatch(it, chapterHref) } val toc = book.getFlatToc() 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 1647218..81987c5 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 @@ -7,6 +7,7 @@ import io.aatricks.easyreader.data.model.ImageRequestPriority import io.aatricks.easyreader.data.model.PrefetchMode import io.aatricks.easyreader.data.model.PrefetchResult import io.aatricks.easyreader.data.repository.HtmlParser +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.util.CacheKeyUtils import io.aatricks.easyreader.util.FileSizeUtils import io.aatricks.easyreader.util.HttpRetry @@ -52,7 +53,8 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( private val parsedContentCache: ParsedContentCache, @HtmlCacheDir private val cacheDir: File, @HtmlDownloadsDir private val downloadsDir: File, - private val permanentFailureStore: PermanentFailureStore + private val permanentFailureStore: PermanentFailureStore, + private val imageDimensionCache: ImageDimensionCacheRepository ) { companion object { private const val TAG = "WebContentLoader" @@ -629,10 +631,21 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( pageUrl: String, diskOnly: Boolean ): List = withContext(Dispatchers.IO) { + val needsLookup = imageElements + .filter { it.width <= 0 || it.height <= 0 } + .map { it.url } + val cached = imageDimensionCache.getMany(needsLookup) + imageElements.map { img -> + if (img.width > 0 && img.height > 0) return@map async { img } + val hit = cached[img.url] + if (hit != null) { + return@map async { img.copy(width = hit.width, height = hit.height) } + } async { DIMENSION_SEMAPHORE.withPermit { fetchImageDimensions(img.url, pageUrl, diskOnly = diskOnly)?.let { (w, h) -> + imageDimensionCache.persist(img.url, w, h) img.copy(width = w, height = h) } ?: img } @@ -1108,6 +1121,12 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( !htmlCached -> false imageUrls.isNotEmpty() -> effectiveCached == imageUrls.size memo?.hasImageTags == true -> false + // A chapter URL with zero parseable images AND zero raw img tags is a JS-rendered + // page (Next.js, SPA) where the static HTML carries only the shell. The + // bodyNonEmpty heuristic that follows is correct for novel text pages but would + // falsely mark these as Downloaded — refuse to claim complete for chapter URLs + // until we actually see images. + isChapterPageUrl(url) -> false else -> memo?.bodyNonEmpty == true } val hasPermanentFailures = imageUrls.isNotEmpty() && accountedPermanent > 0 @@ -1125,6 +1144,14 @@ class WebContentLoader @Suppress("LongParameterList") @Inject constructor( ) } + private fun isChapterPageUrl(url: String): Boolean { + val lower = url.lowercase() + return lower.contains("/chapter/") || + lower.contains("/chapter-") || + lower.contains("-chapter-") || + (lower.contains("/manga/") && lower.contains("chapter")) + } + private fun resolveParsedImageMemo( url: String, htmlFile: File?, diff --git a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt index e9f390f..b35d1b9 100644 --- a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt +++ b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import io.aatricks.easyreader.data.local.AppDatabase import io.aatricks.easyreader.data.local.ChapterImageStateDao +import io.aatricks.easyreader.data.local.ImageDimensionDao import io.aatricks.easyreader.data.local.LibraryDao import javax.inject.Singleton @@ -30,7 +31,8 @@ object DatabaseModule { AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, - AppDatabase.MIGRATION_7_8 + AppDatabase.MIGRATION_7_8, + AppDatabase.MIGRATION_8_9 ) .build() } @@ -46,4 +48,10 @@ object DatabaseModule { fun provideChapterImageStateDao(database: AppDatabase): ChapterImageStateDao { return database.chapterImageStateDao() } + + @Provides + @Singleton + fun provideImageDimensionDao(database: AppDatabase): ImageDimensionDao { + return database.imageDimensionDao() + } } 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 89e0488..40c26b6 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 @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -50,8 +51,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.abs -private const val RESTORE_SMOKE_CHECK_DELAY_MS = 500L private const val RESTORE_PERCENT_TOLERANCE = 5f +private const val RESTORE_STABILITY_POLL_INTERVAL_MS = 80L +private const val RESTORE_STABILITY_DURATION_MS = 300L +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 +// hops as the LazyList re-measures intermediate sizes. +private const val RESTORE_REJUMP_THRESHOLD = 0.20f @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable @@ -101,7 +108,15 @@ internal fun ContentArea( // 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) { - if (content.paragraphs.isEmpty()) return@LaunchedEffect + // 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) @@ -112,12 +127,14 @@ internal fun ContentArea( // 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 } @@ -125,32 +142,71 @@ internal fun ContentArea( runCatching { listState.scrollToItem(targetIndex, 0) } if (targetFraction == null || targetFraction == 0f) { + readerViewModel.markRestoreDone() return@LaunchedEffect } - // Wait for the target item to reach a meaningful size — async image loads pass through - // a placeholder height first, and applying the fraction against the placeholder - // produces a meaningless offset. - val itemSize = snapshotFlow { - listState.layoutInfo.visibleItemsInfo + // Watch-until-stable: re-apply the intra-item fraction every time the target item + // grows significantly (image decode reflow), and lock in once the size is unchanged + // for RESTORE_STABILITY_DURATION_MS. Bails immediately on a real user drag. A 3s + // hard cap prevents runaway loops on very slow networks. + val deadline = System.currentTimeMillis() + RESTORE_MAX_WAIT_MS + var lastAppliedSize = 0 + var stableSince = 0L + + while (System.currentTimeMillis() < deadline && !readerViewModel.userHasDragged) { + val size = listState.layoutInfo.visibleItemsInfo .firstOrNull { it.index == targetIndex } ?.size ?: 0 - }.first { it >= MIN_STABLE_ITEM_SIZE_PX } - - val targetOffsetPx = (itemSize * targetFraction).toInt().coerceIn(0, itemSize) - runCatching { listState.scrollToItem(targetIndex, targetOffsetPx) } - - // Self-heal smoke check — verify after layout has settled. Skip if the user has already - // taken the wheel: yanking them away from their own scroll is worse than tolerating a - // small restore mismatch. - kotlinx.coroutines.delay(RESTORE_SMOKE_CHECK_DELAY_MS) - if (readerViewModel.hasUserInteractedSinceLoad) return@LaunchedEffect - 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) } + if (size >= MIN_STABLE_ITEM_SIZE_PX) { + if (size == lastAppliedSize) { + if (stableSince == 0L) stableSince = System.currentTimeMillis() + if (System.currentTimeMillis() - stableSince >= RESTORE_STABILITY_DURATION_MS) { + val offsetPx = (size * targetFraction).toInt().coerceIn(0, size) + runCatching { listState.scrollToItem(targetIndex, offsetPx) } + break + } + } else { + val grewEnough = lastAppliedSize == 0 || + abs(size - lastAppliedSize).toFloat() / lastAppliedSize.toFloat() >= RESTORE_REJUMP_THRESHOLD + if (grewEnough) { + val offsetPx = (size * targetFraction).toInt().coerceIn(0, size) + runCatching { listState.scrollToItem(targetIndex, offsetPx) } + lastAppliedSize = size + } + stableSince = 0L + } + } + 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. + if (!readerViewModel.userHasDragged) { + 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() + } + + // Detect genuine user drags on the LazyList. Programmatic scrollToItem calls do NOT + // emit DragInteraction.Start, so the restore loop's own movements won't trip the flag. + // Tap-to-toggle-controls fires PressInteraction.Press but should NOT count as "user + // wants this position saved" — only Drag confirms that intent. + if (!uiState.isPagedMode) { + LaunchedEffect(listState, content.url) { + listState.interactionSource.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) { + readerViewModel.markUserDragged() + } + } } } @@ -178,6 +234,14 @@ internal fun ContentArea( if (event != Lifecycle.Event.ON_PAUSE && event != Lifecycle.Event.ON_STOP) return@LifecycleEventObserver if (uiState.isPagedMode) return@LifecycleEventObserver + // If restore is still running and the user has not actually scrolled the + // content, the current listState position is the (possibly mid-reflow) + // restore landing — never the user's intent. Persisting it here would + // overwrite the saved row with a worse approximation of itself. + if (readerViewModel.restoreInProgress && !readerViewModel.userHasDragged) { + return@LifecycleEventObserver + } + val snapshot = buildScrollSnapshot(listState, content) ?: return@LifecycleEventObserver readerViewModel.updateScrollPosition( scrollOffset = snapshot.scrollOffset, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt index 9cc87c7..4037a7a 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt @@ -160,6 +160,9 @@ internal fun PagedReaderView( zoomStateKey = "${content.url}_${page}_${subElement.url}_${subElement.side}", onZoomChanged = if (isZoomable) onPageZoomChanged else null, lockTapWhileZoomed = isZoomable, + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = { readerViewModel.toggleControls() } ) } @@ -211,6 +214,9 @@ internal fun PagedReaderView( zoomStateKey = "${content.url}_${page}_${el.url}_${el.side}", onZoomChanged = if (isZoomable) onPageZoomChanged else null, lockTapWhileZoomed = isZoomable, + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = { readerViewModel.toggleControls() } ) } @@ -266,6 +272,9 @@ private fun PagedImageGroupView( enableZoom = false, dynamicHeight = true, zoomStateKey = "${pageUrl}_${pageIndex}_group_$index", + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = null ) } @@ -395,6 +404,9 @@ internal fun ScrollingReaderView( side = subElement.side, enableZoom = false, dynamicHeight = false, + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = { readerViewModel.toggleControls() } ) } @@ -438,6 +450,9 @@ internal fun ScrollingReaderView( side = element.side, enableZoom = false, dynamicHeight = false, + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = { readerViewModel.toggleControls() } ) } @@ -460,6 +475,9 @@ internal fun ScrollingReaderView( side = img.side, enableZoom = false, dynamicHeight = false, + onDimensionsResolved = { url, w, h -> + readerViewModel.persistImageDimensions(url, w, h) + }, onTap = { readerViewModel.toggleControls() } ) } 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 8526279..106eb2a 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 @@ -30,6 +30,22 @@ class ReaderProgressController( var hasUserInteractedSinceLoad: Boolean = false var restoredProgressSnapshot: ReaderProgressState? = null + /** + * True only after a confirmed user drag/press on the reader content. Distinct from + * `hasUserInteractedSinceLoad`, which also flips on programmatic scroll-position changes + * (e.g. the restore loop's own `scrollToItem`). Self-heal / smoke-check logic gates on + * this flag instead — yanking a user mid-gesture is worse than tolerating a small drift, + * but reflow-induced scroll updates should never disable the self-heal. + */ + var userHasDragged: Boolean = false + + /** + * True from `calculateInitialPosition` until the restore loop in the UI layer calls + * `markRestoreDone()`. While set (and the user has not yet dragged), DB writes from + * scroll snapshots are suppressed because the layout is still reflowing as images decode. + */ + var restoreInProgress: Boolean = false + private var lastRawScrollOffset: Float = -1f private var lastReportedIndex: Int = -1 private var lastReportedFractionMillis: Int = -1 @@ -67,6 +83,8 @@ class ReaderProgressController( ): ReaderProgressState { suppressAutoNavUntilUserInteraction = true hasUserInteractedSinceLoad = false + userHasDragged = false + restoreInProgress = true if (libraryItem == null || isExplicitNavigation) { restoredScrollPercent = if (fromBottom) 100f else 0f @@ -153,6 +171,30 @@ class ReaderProgressController( return ResolvedPosition(index = derivedIndex, elementKey = refreshedKey, fraction = 0f) } + fun markUserDragged() { + userHasDragged = true + hasUserInteractedSinceLoad = true + suppressAutoNavUntilUserInteraction = false + restoredProgressSnapshot = null + restoreInProgress = false + } + + fun markRestoreDone() { + restoreInProgress = false + } + + /** + * Called by the UI restore LaunchedEffect at entry. Mirrors [calculateInitialPosition]'s + * flag setup so that mid-flight UI-driven restores (e.g. seek-bar drags that bump + * `seekTrigger`) re-arm the gating even though they don't go through + * [calculateInitialPosition]. Without this, a second restore proceeds with stale + * `restoreInProgress=false`, letting mid-decode saves poison the just-seeked position. + */ + fun beginRestore() { + restoreInProgress = true + userHasDragged = false + } + fun onUserInteraction( uiTargetScrollPosition: Float?, uiPendingRestoreOffsetFraction: Float?, @@ -168,8 +210,12 @@ class ReaderProgressController( if (!requiresInteractionCleanup) return + // Callers are paths that only fire on confirmed user input (nested scroll with + // touchSlop, seek bar, nav buttons). Treat as a drag for restore-gating purposes. hasUserInteractedSinceLoad = true + userHasDragged = true suppressAutoNavUntilUserInteraction = false + restoreInProgress = false restoredProgressSnapshot = null val nextUiTargetScrollPosition = if (uiTargetScrollPosition != null) null else uiTargetScrollPosition @@ -223,7 +269,25 @@ class ReaderProgressController( // Item measured but at placeholder size — fraction is meaningless. Drop write. return false } - return !isPlaceholderAtCurrentPosition(content, snapshot.scrollIndex) + if (isPlaceholderAtCurrentPosition(content, snapshot.scrollIndex)) return false + // If the first visible item is an image whose dimensions are still unknown, the + // current `firstVisibleItemSize` is whatever Coil has decoded so far — not the + // final layout size. Persisting the fraction against it stamps a position that + // will drift once decode completes. Skip writes until dims are known (either + // declared in the parser, or cached from a prior open, or runtime-resolved by + // Coil and threaded back to the parser). + if (!isFirstVisibleItemDimensionsKnown(content, snapshot.scrollIndex)) return false + return true + } + + private fun isFirstVisibleItemDimensionsKnown(content: ChapterContent?, index: Int): Boolean { + val item = content?.paragraphs?.getOrNull(index) ?: return true + return when (item) { + is ContentElement.Image -> item.width > 0 && item.height > 0 + is ContentElement.ImageGroup -> item.images.isEmpty() || + item.images.all { it.width > 0 && it.height > 0 } + else -> true + } } fun updateScrollPosition( @@ -263,6 +327,15 @@ class ReaderProgressController( return } + // While the restore loop is still settling layout — and the user has not yet + // touched the screen — skip DB writes entirely. The in-memory state still updates + // below so the seek bar/UI stay live, but we refuse to overwrite the saved row + // with positions produced by image-decode-induced reflow. + if (restoreInProgress && !userHasDragged) { + lastRawScrollOffset = scrollOffset + return + } + val isStable = firstVisibleItemSize >= MIN_STABLE_ITEM_SIZE_PX val effectiveFraction = if (isStable) offsetFraction.coerceIn(0f, 1f) else FRACTION_UNKNOWN @@ -349,6 +422,8 @@ class ReaderProgressController( _progressState.value = ReaderProgressState() currentLibraryItemId = null hasUserInteractedSinceLoad = false + userHasDragged = false + restoreInProgress = false restoredProgressSnapshot = null lastRawScrollOffset = -1f lastReportedIndex = -1 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 2d8e390..4a97cae 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 @@ -9,6 +9,7 @@ import io.aatricks.easyreader.data.model.* import io.aatricks.easyreader.data.repository.ChapterListCache import io.aatricks.easyreader.data.repository.ContentRepository import io.aatricks.easyreader.data.repository.ExploreRepository +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.data.repository.LibraryRepository import io.aatricks.easyreader.ui.theme.AccentTheme import io.aatricks.easyreader.util.normalizeChapterList @@ -37,7 +38,8 @@ class ReaderViewModel @Inject constructor( private val libraryRepository: LibraryRepository, private val exploreRepository: ExploreRepository, private val preferencesManager: PreferencesManager, - private val chapterListCache: ChapterListCache + private val chapterListCache: ChapterListCache, + private val imageDimensionCache: ImageDimensionCacheRepository ) : BaseViewModel(ReaderUiState()) { private val progressController = ReaderProgressController(libraryRepository, viewModelScope) val progressState: StateFlow = progressController.progressState @@ -66,6 +68,31 @@ class ReaderViewModel @Inject constructor( get() = progressController.hasUserInteractedSinceLoad private set(value) { progressController.hasUserInteractedSinceLoad = value } + val userHasDragged: Boolean + get() = progressController.userHasDragged + + val restoreInProgress: Boolean + get() = progressController.restoreInProgress + + fun markUserDragged() { + progressController.markUserDragged() + } + + fun markRestoreDone() { + progressController.markRestoreDone() + } + + fun beginRestore() { + progressController.beginRestore() + } + + fun persistImageDimensions(imageUrl: String, width: Int, height: Int) { + if (imageUrl.isBlank() || width <= 0 || height <= 0) return + viewModelScope.launch { + imageDimensionCache.persist(imageUrl, width, height) + } + } + private var restoredProgressSnapshot: ReaderProgressState? get() = progressController.restoredProgressSnapshot set(value) { progressController.restoredProgressSnapshot = value } diff --git a/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt index 607badd..5b61d5d 100644 --- a/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt +++ b/app/src/main/java/io/aatricks/easyreader/util/ImageIntegrity.kt @@ -1,21 +1,14 @@ package io.aatricks.easyreader.util import java.io.File -import java.io.RandomAccessFile /** - * Integrity check for cached image files. Catches three failure modes that - * `File.exists()` alone would miss: + * Shallow integrity check for cached image files. Catches the failure modes + * that `File.exists()` alone would miss without rejecting valid CDN-served + * files that have format-specific metadata, padding, or container variants: * 1. Zero-byte / single-byte files. * 2. HTML error pages (Cloudflare/CDN challenges) returned with an image content-type. - * 3. Truncated downloads — a file with the correct magic header but missing trailer. - * Coil's decoder rejects these at read time and the user sees "Image unavailable" - * while the chapter badge still says Downloaded. The structural trailer check below - * keeps inspect honest with what the reader can actually decode. - * - * Trailer check covers JPEG/PNG/WebP — the formats real manga sources use. GIF/BMP/AVIF/SVG - * fall back to the magic-byte-only check; if we encounter widespread truncation there we - * add their trailers too. + * 3. Totally wrong payloads with no supported image magic bytes. */ object ImageIntegrity { // Conservative lower bound — only rejects obviously-truncated downloads (zero bytes, @@ -24,28 +17,11 @@ object ImageIntegrity { // alone is 8 bytes, JPEG SOI is 2 bytes, WebP needs 12 bytes for the RIFF+WEBP brand. private const val MIN_VALID_IMAGE_BYTES = 16L private const val SNIFF_BYTES = 32 - // Real CDN-served images often append metadata, watermarks, or padding after the - // format's spec-required end marker. Search this many bytes back from EOF so we accept - // valid images that aren't bit-for-bit spec-pure while still catching truncations - // (which omit the marker entirely). - private const val TRAILER_SCAN_BYTES = 512 fun isValidImageFile(file: File): Boolean { if (!file.exists() || file.length() < MIN_VALID_IMAGE_BYTES) return false val header = readHeader(file) ?: return false - val kind = classifyFormat(header) ?: return false - return when (kind) { - ImageFormat.JPEG -> jpegLooksComplete(file) - ImageFormat.PNG -> pngLooksComplete(file) - ImageFormat.WEBP -> webpLooksComplete(file) - // No trailer check defined; magic check alone — we accept these because the - // download path is harder to truncate transparently and these formats are rare - // in real chapter content. - ImageFormat.GIF, - ImageFormat.BMP, - ImageFormat.AVIF_HEIF, - ImageFormat.SVG -> true - } + return classifyFormat(header) != null } private enum class ImageFormat { JPEG, PNG, GIF, WEBP, BMP, AVIF_HEIF, SVG } @@ -95,58 +71,6 @@ object ImageIntegrity { return false } - // JPEG EOI marker is FF D9. The spec puts it at end-of-file but many CDN-served JPEGs - // append metadata/watermarks/padding after EOI. Scan the trailing window; a truncation - // omits the marker entirely so absence is still a reliable failure signal. - private val jpegEoi = byteArrayOf(0xFF.toByte(), 0xD9.toByte()) - private fun jpegLooksComplete(file: File): Boolean { - val tail = readTrailer(file, TRAILER_SCAN_BYTES) ?: return false - return indexOfLast(tail, jpegEoi) >= 0 - } - - // PNG IEND chunk header: 4-byte length (always 0) + 4-byte type "IEND". Scan trailing - // window — same rationale as JPEG: trailing bytes after IEND occur in the wild. - private val pngIendHeader = byteArrayOf( - 0x00, 0x00, 0x00, 0x00, - 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte() - ) - private fun pngLooksComplete(file: File): Boolean { - val tail = readTrailer(file, TRAILER_SCAN_BYTES) ?: return false - return indexOfLast(tail, pngIendHeader) >= 0 - } - - private fun indexOfLast(haystack: ByteArray, needle: ByteArray): Int { - if (needle.isEmpty() || haystack.size < needle.size) return -1 - outer@ for (start in (haystack.size - needle.size) downTo 0) { - for (i in needle.indices) { - if (haystack[start + i] != needle[i]) continue@outer - } - return start - } - return -1 - } - - // WebP RIFF header declares size of (file - 8). If the file is shorter than that, it - // was cut off; longer is OK because chunks can be padded. Header layout: - // bytes 0..3 = "RIFF" - // bytes 4..7 = little-endian uint32 chunk size (covers bytes 8..end) - // bytes 8..11 = "WEBP" - private fun webpLooksComplete(file: File): Boolean { - val header = runCatching { - file.inputStream().use { stream -> - val bytes = ByteArray(12) - val read = stream.read(bytes) - if (read == 12) bytes else null - } - }.getOrNull() ?: return false - val declared = (header[4].toInt() and 0xFF) or - ((header[5].toInt() and 0xFF) shl 8) or - ((header[6].toInt() and 0xFF) shl 16) or - ((header[7].toInt() and 0xFF) shl 24) - // declared is the size from offset 8 onward; total file size therefore is declared + 8. - return file.length() >= declared.toLong() + 8L - } - private fun readHeader(file: File): ByteArray? = runCatching { file.inputStream().use { stream -> val bytes = ByteArray(SNIFF_BYTES) @@ -154,21 +78,4 @@ object ImageIntegrity { if (read <= 0) null else bytes.copyOf(read) } }.getOrNull() - - private fun readTrailer(file: File, byteCount: Int): ByteArray? = runCatching { - val length = file.length() - val want = byteCount.toLong().coerceAtMost(length).toInt() - if (want <= 0) return@runCatching null - RandomAccessFile(file, "r").use { raf -> - raf.seek(length - want) - val bytes = ByteArray(want) - var read = 0 - while (read < want) { - val n = raf.read(bytes, read, want - read) - if (n == -1) return@runCatching null - read += n - } - bytes - } - }.getOrNull() } diff --git a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt index d058d9d..47327fd 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt @@ -696,7 +696,7 @@ class AppDatabaseMigrationTest { """.trimIndent() companion object { - private const val CURRENT_VERSION = 8 + private const val CURRENT_VERSION = 9 private val ALL_MIGRATIONS = arrayOf( AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, @@ -704,7 +704,8 @@ class AppDatabaseMigrationTest { AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, - AppDatabase.MIGRATION_7_8 + AppDatabase.MIGRATION_7_8, + AppDatabase.MIGRATION_8_9 ) private val CURRENT_INDEX_SQL = listOf( "CREATE UNIQUE INDEX index_library_items_url ON library_items (url)", diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt index 09b31de..2e56e9c 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/ContentRepositoryEpubTest.kt @@ -15,6 +15,7 @@ import io.aatricks.easyreader.data.repository.content.WebContentLoader import io.aatricks.easyreader.data.repository.content.ImageCache import io.aatricks.easyreader.data.repository.content.ImageDownloader import io.aatricks.easyreader.data.repository.content.ParsedContentCache +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -89,9 +90,10 @@ class ContentRepositoryEpubTest { // Use anyString() for Uri.parse mockedUriStatic.`when` { Uri.parse(org.mockito.ArgumentMatchers.anyString()) }.thenReturn(mockUri) - val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCache, mediaDownloads), ImageDownloader(okHttpClient), ParsedContentCache(), htmlCache, htmlDownloads, io.aatricks.easyreader.data.repository.content.InMemoryPermanentFailureStore()) + val imageDimCache = fakeImageDimensionCacheRepository() + val webLoader = WebContentLoader(mockHtmlParser, okHttpClient, ImageCache(mediaCache, mediaDownloads), ImageDownloader(okHttpClient), ParsedContentCache(), htmlCache, htmlDownloads, io.aatricks.easyreader.data.repository.content.InMemoryPermanentFailureStore(), imageDimCache) val pdfLoader = PdfContentLoader(mockContext, DefaultPdfDocumentOpener(mockContext)) - val epubLoader = EpubContentLoader(mockContext, epubCache, epubDownloads) + val epubLoader = EpubContentLoader(mockContext, epubCache, epubDownloads, imageDimCache) val localLoader = LocalContentLoader(mockContext, mockHtmlParser, pdfLoader, epubLoader, contentUriTypeResolver) repository = ContentRepository(webLoader, pdfLoader, epubLoader, localLoader, contentUriTypeResolver, mockContext, okHttpClient) diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt index 2843652..6004bf8 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderPrefetchRetryTest.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.data.repository.content import io.aatricks.easyreader.data.model.ContentElement import io.aatricks.easyreader.data.model.PrefetchMode import io.aatricks.easyreader.data.repository.HtmlParser +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import io.aatricks.easyreader.util.CacheKeyUtils import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -248,7 +249,7 @@ class WebContentLoaderPrefetchRetryTest { val loader = WebContentLoader( htmlParser, client, ImageCache(mediaCacheDir, mediaDownloadsDir), ImageDownloader(client), ParsedContentCache(), htmlCacheDir, htmlDownloadsDir, - store + store, fakeImageDimensionCacheRepository() ) return LoaderHarness(loader, htmlDownloadsDir, store) } diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt index 4ea03ee..c4d18df 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/content/WebContentLoaderTest.kt @@ -5,6 +5,7 @@ import io.aatricks.easyreader.data.model.ContentResult import io.aatricks.easyreader.data.model.ImageRequestPriority import io.aatricks.easyreader.data.model.PrefetchMode import io.aatricks.easyreader.data.repository.HtmlParser +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancelAndJoin @@ -697,7 +698,7 @@ class WebContentLoaderTest { .build() val imageCache = ImageCache(mediaCacheDir, mediaDownloadsDir) val imageDownloader = ImageDownloader(client) - return WebContentLoader(htmlParser, client, imageCache, imageDownloader, ParsedContentCache(), htmlCacheDir, htmlDownloadsDir, InMemoryPermanentFailureStore()) + return WebContentLoader(htmlParser, client, imageCache, imageDownloader, ParsedContentCache(), htmlCacheDir, htmlDownloadsDir, InMemoryPermanentFailureStore(), fakeImageDimensionCacheRepository()) } private fun buildResponse( @@ -708,9 +709,8 @@ class WebContentLoaderTest { ): Response { val payload = if (code == 200 && contentType.startsWith("image/")) { val bodyBytes = body.toByteArray() - // ImageIntegrity now requires a structural trailer (EOI marker for JPEG) so the - // fixture must look like a complete file end-to-end. Pad to ≥64 bytes and finish - // with FF D9 so inspect treats the body as a valid (if minimal) JPEG. + // ImageIntegrity requires recognizable image magic bytes; wrap string bodies in + // a minimal JPEG-looking payload so cache inspection accepts the fixture. val header = VALID_JPEG_HEADER + bodyBytes val padded = if (header.size < 62) header + ByteArray(62 - header.size) else header padded + JPEG_EOI @@ -727,10 +727,8 @@ class WebContentLoaderTest { } private companion object { - // ImageIntegrity validates downloaded files by magic bytes AND structural trailer. - // Test fixtures use string bodies like "image-body" that wouldn't pass; wrap them - // in a JPEG SOI/APP0 header and EOI trailer so the integrity check accepts them - // without requiring real image payloads. + // Test fixtures use string bodies like "image-body" that would not pass image + // sniffing; wrap them in a JPEG SOI/APP0 header without requiring real image bytes. private val VALID_JPEG_HEADER = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) private val JPEG_EOI = byteArrayOf(0xFF.toByte(), 0xD9.toByte()) } diff --git a/app/src/test/java/io/aatricks/easyreader/testutil/FakeImageDimensionCache.kt b/app/src/test/java/io/aatricks/easyreader/testutil/FakeImageDimensionCache.kt new file mode 100644 index 0000000..16b632b --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/testutil/FakeImageDimensionCache.kt @@ -0,0 +1,33 @@ +package io.aatricks.easyreader.testutil + +import io.aatricks.easyreader.data.local.ImageDimensionDao +import io.aatricks.easyreader.data.model.ImageDimensionEntity +import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository + +/** In-memory `ImageDimensionDao` so tests don't have to spin up Room. */ +class FakeImageDimensionDao : ImageDimensionDao { + private val store = mutableMapOf() + + override suspend fun getMany(urls: List, parserVersion: Int): List { + val set = urls.toHashSet() + return store.values.filter { it.imageUrl in set && it.parserVersion == parserVersion } + } + + override suspend fun upsert(entity: ImageDimensionEntity) { + store[entity.imageUrl] = entity + } + + override suspend fun upsertAll(entities: List) { + entities.forEach { store[it.imageUrl] = it } + } + + override suspend fun prune(cutoffMs: Long, currentParserVersion: Int) { + store.values + .filter { it.cachedAtMs < cutoffMs || it.parserVersion < currentParserVersion } + .map { it.imageUrl } + .forEach { store.remove(it) } + } +} + +fun fakeImageDimensionCacheRepository(): ImageDimensionCacheRepository = + ImageDimensionCacheRepository(FakeImageDimensionDao()) diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt index 266c0b7..b73cbe5 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt @@ -5,6 +5,7 @@ import io.aatricks.easyreader.data.model.* import io.aatricks.easyreader.data.repository.ContentRepository import io.aatricks.easyreader.data.repository.ExploreRepository import io.aatricks.easyreader.data.repository.LibraryRepository +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -81,7 +82,8 @@ class ReaderViewModelNavigationTest { libraryRepository, exploreRepository, preferencesManager, - chapterListCache + chapterListCache, + fakeImageDimensionCacheRepository() ) } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelSecurityTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelSecurityTest.kt index 7b66289..4e7b3d3 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelSecurityTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelSecurityTest.kt @@ -4,6 +4,7 @@ import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.repository.ContentRepository import io.aatricks.easyreader.data.repository.ExploreRepository import io.aatricks.easyreader.data.repository.LibraryRepository +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -61,7 +62,8 @@ class ReaderViewModelSecurityTest { libraryRepository, exploreRepository, preferencesManager, - chapterListCache + chapterListCache, + fakeImageDimensionCacheRepository() ) } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt index 7535cf1..eab7568 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt @@ -7,6 +7,7 @@ import io.aatricks.easyreader.data.model.ReadingMode import io.aatricks.easyreader.data.repository.ContentRepository import io.aatricks.easyreader.data.repository.ExploreRepository import io.aatricks.easyreader.data.repository.LibraryRepository +import io.aatricks.easyreader.testutil.fakeImageDimensionCacheRepository import io.aatricks.easyreader.util.FieldUpdate import io.aatricks.easyreader.util.computeAutoDeleteCandidates import kotlinx.coroutines.CancellationException @@ -88,7 +89,8 @@ class ReaderViewModelTest { libraryRepository, exploreRepository, preferencesManager, - chapterListCache + chapterListCache, + fakeImageDimensionCacheRepository() ) } @@ -585,7 +587,13 @@ class ReaderViewModelTest { val url = "https://example.com/manhwa/10" whenever(contentRepository.loadContent(url)).thenReturn( - ContentResult.Success(listOf(ContentElement.Image("https://cdn.example.com/1.jpg")), "Chapter 10", url) + ContentResult.Success( + // Declared dims so the lifecycle-save dim-known gate lets this through: + // an unknown-dim image is treated as mid-reflow and rightly drops the save. + listOf(ContentElement.Image(url = "https://cdn.example.com/1.jpg", width = 800, height = 1200)), + "Chapter 10", + url + ) ) whenever(libraryRepository.getItemByUrl(url)).thenReturn( LibraryItem(id = itemId, title = "Chapter 10", url = url) @@ -866,7 +874,8 @@ class ReaderViewModelTest { libraryRepository, exploreRepository, preferencesManager, - chapterListCache + chapterListCache, + fakeImageDimensionCacheRepository() ) viewModel.loadContent(url) diff --git a/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt index 2f8b43f..af55820 100644 --- a/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/util/ImageIntegrityTest.kt @@ -60,13 +60,13 @@ class ImageIntegrityTest { } @Test - fun `rejects JPEG missing EOI trailer (truncated mid-image)`() { - // Truncated download: header parses fine, but body is cut and EOI never written. - // Previously this passed the magic-byte check and inspect counted it as Downloaded. + fun `accepts JPEG by magic bytes without requiring EOI trailer`() { + // CDN JPEGs are not always spec-pure at EOF. The integrity check stays shallow and + // lets the decoder decide structural completeness. val payload = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + ByteArray(60) val file = tempFolder.newFile("truncated.jpg").apply { writeBytes(payload) } - assertFalse(ImageIntegrity.isValidImageFile(file)) + assertTrue(ImageIntegrity.isValidImageFile(file)) } @Test @@ -85,12 +85,12 @@ class ImageIntegrityTest { } @Test - fun `rejects PNG missing IEND chunk (truncated)`() { + fun `accepts PNG by signature without requiring IEND chunk`() { val signature = byteArrayOf( 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ) val file = tempFolder.newFile("truncated.png").apply { writeBytes(signature + ByteArray(32)) } - assertFalse(ImageIntegrity.isValidImageFile(file)) + assertTrue(ImageIntegrity.isValidImageFile(file)) } @Test @@ -106,15 +106,16 @@ class ImageIntegrityTest { } @Test - fun `rejects WebP truncated below declared chunk size`() { - // RIFF claims 200 bytes of payload but file only carries 20 — truncated. + fun `accepts WebP by RIFF WEBP magic without enforcing declared chunk size`() { + // Extended/animated WebPs and CDN transformations are handled by the platform + // decoder; cache integrity only rejects obvious non-images. val webp = byteArrayOf( 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), 200.toByte(), 0, 0, 0, 'W'.code.toByte(), 'E'.code.toByte(), 'B'.code.toByte(), 'P'.code.toByte() ) + ByteArray(20) val file = tempFolder.newFile("truncated.webp").apply { writeBytes(webp) } - assertFalse(ImageIntegrity.isValidImageFile(file)) + assertTrue(ImageIntegrity.isValidImageFile(file)) } @Test From f212a15d8a31652c80b78a6ea78853ce662c39a6 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Thu, 21 May 2026 23:45:32 -0400 Subject: [PATCH 09/19] Handle JS-extracted manga images and prefetch modes Merge image URLs extracted from inline scripts into parsed image lists for JS-driven chapter pages (Mangabat/Asura/Astro patterns) and filter decorative URLs. Add extractInlineScriptImageUrls and helper heuristics. Add hasMangaReaderHints/detectMangaReaderHints so pages that are manga readers but only carry a JS shell aren't marked Downloaded with zero images. Track in-flight chapter prefetches with mode + deferred and ensure a USER_REQUESTED prefetch does not reuse a SPECULATIVE one (cancel and replace speculative reservations as needed) --- .../easyreader/data/repository/HtmlParser.kt | 82 ++++++++++++++++++- .../repository/content/WebContentLoader.kt | 76 +++++++++++++++-- 2 files changed, 148 insertions(+), 10 deletions(-) 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 a5dedb6..75864da 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 @@ -50,11 +50,25 @@ class HtmlParser @Inject constructor() { fun parse(document: Document, url: String): List { cleanDocument(document) - val images = parseImages(document, url) + var images = parseImages(document, url) val paragraphs = parseParagraphs(document) val filteredParagraphs = filterParagraphs(paragraphs, document.title()) + // Some manga sites (Mangabat, Asura) embed the chapter image list in inline JS + // (`chapterImages = [...]`, Astro page-island JSON, etc.) and only hardcode the + // cover image as a static . Without this fallback the parser sees 1 image, + // the downloader marks the chapter "complete" after fetching the cover, and the + // reader shows a single page offline. Merge JS-extracted URLs into the image list + // for chapter URLs where the static parse looks suspiciously thin. + if (isChapterPage(url) && images.size <= 2) { + val jsImages = extractInlineScriptImageUrls(document, url, alreadyKnown = images.map { it.url }.toSet()) + if (jsImages.isNotEmpty()) { + images = (images + jsImages.map { ContentElement.Image(url = it, width = 0, height = 0) }) + .distinctBy { it.url } + } + } + // Chapter pages from manga sites legitimately have many paragraphs of footer text // (comments, ads, "if you want to read free manga..."). If any chapter image was // extracted from a manga reader container, trust that this is image content and @@ -71,6 +85,72 @@ class HtmlParser @Inject constructor() { return mergeAndFormatParagraphs(filteredParagraphs) } + /** + * Mangabat / Asura / other JS-rendered chapter pages embed the real page-image list + * inside `