From b7f49ebd351dd4aa2cfabccdda13b4f869456ba9 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 22:05:28 -0400 Subject: [PATCH 1/5] Fix(coroutines): rethrow CancellationException across runCatching boundaries Cancelled coroutines were being treated as failures: BaseViewModel flashed 'unknown error' states, ContentRepository fabricated retryable results (and inspectCache memoized them for 3s), and repositories logged cancellations as errors. Add Result.rethrowCancellation() and apply it at the confirmed sites. Also guard ImageDimensionManager's dimension flush against persistAll failures crashing the scope, and make openNewChapter single-flight so rapid taps don't run concurrent fetches. --- .../data/repository/ContentRepository.kt | 9 ++- .../easyreader/ui/viewmodel/BaseViewModel.kt | 2 + .../ui/viewmodel/ImageDimensionManager.kt | 13 +++- .../ui/viewmodel/LibraryViewModel.kt | 9 ++- .../easyreader/util/ResultExtensions.kt | 12 +++ .../ui/viewmodel/BaseViewModelTest.kt | 75 +++++++++++++++++++ 6 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt index de2b58e3..d4d95850 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt @@ -6,6 +6,7 @@ import io.aatricks.easyreader.data.model.* import io.aatricks.easyreader.data.repository.content.* import io.aatricks.easyreader.data.repository.content.StorageTier import io.aatricks.easyreader.util.FileSizeUtils +import io.aatricks.easyreader.util.rethrowCancellation import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.CancellationException @@ -235,7 +236,7 @@ class ContentRepository @Inject constructor( isRetryable = false ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true) } trimCachesInternal(force = true) @@ -285,7 +286,7 @@ class ContentRepository @Inject constructor( isPersistentDownload = true ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult( url, htmlCached = false, @@ -328,7 +329,7 @@ class ContentRepository @Inject constructor( isPersistentDownload = true ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult( url, htmlCached = false, @@ -363,7 +364,7 @@ class ContentRepository @Inject constructor( isRetryable = false ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt index efddaa87..c81efa44 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt @@ -2,6 +2,7 @@ package io.aatricks.easyreader.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.aatricks.easyreader.util.rethrowCancellation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +30,7 @@ abstract class BaseViewModel(initialState: S) : ViewModel() { if (handleError) updateState { errorState(it, null) } runCatching { block() } + .rethrowCancellation() .onFailure { e -> if (handleError) updateState { errorState(it, e.message ?: "An unknown error occurred") } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index 8ef5ca97..e50a2bc8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import android.util.Log +import io.aatricks.easyreader.util.rethrowCancellation /** * Owns the resolved-image-dimension pipeline extracted from ReaderViewModel. @@ -32,6 +34,7 @@ class ImageDimensionManager( private var contentDimApplyJob: Job? = null companion object { + private const val TAG = "ImageDimensionManager" private const val IMAGE_DIMENSION_FLUSH_DELAY_MS = 100L private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L } @@ -78,9 +81,13 @@ class ImageDimensionManager( val updates = pendingImageDimensions.toMap() pendingImageDimensions.clear() if (updates.isEmpty()) return - imageDimensionCache.persistAll(updates.map { (url, dimensions) -> - Triple(url, dimensions.first, dimensions.second) - }) + runCatching { + imageDimensionCache.persistAll(updates.map { (url, dimensions) -> + Triple(url, dimensions.first, dimensions.second) + }) + }.rethrowCancellation().onFailure { e -> + Log.e(TAG, "Failed to flush pending image dimensions", e) + } } // Matches the reader's resetState: drop the queued in-memory rebuild and the resolved map. 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 f0079374..42d15384 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 @@ -19,6 +19,8 @@ import io.aatricks.easyreader.work.ChapterDownloadQueue import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import io.aatricks.easyreader.util.rethrowCancellation import javax.inject.Inject import android.util.Log @@ -347,6 +349,8 @@ class LibraryViewModel @Inject constructor( return true } + private var openNewChapterJob: Job? = null + private suspend fun addUnresolvedItem(url: String, contentType: ContentType) { val fetchedTitle = runCatching { contentRepository.fetchTitle(url) }.getOrNull() ?: url val fullTitle = fetchedTitle.trim().ifBlank { url } @@ -368,7 +372,8 @@ class LibraryViewModel @Inject constructor( sourceName: String, onChapterLoaded: (String, String) -> Unit ): Unit { - viewModelScope.launch { + if (openNewChapterJob?.isActive == true) return + openNewChapterJob = viewModelScope.launch { runCatching { updateState { it.copy(isLoading = true) } val details = exploreRepository.getNovelDetails(baseNovelUrl, sourceName) @@ -401,7 +406,7 @@ class LibraryViewModel @Inject constructor( repository.clearUpdateIndicator(item.id) onChapterLoaded(item.url, item.id) updateState { it.copy(isLoading = false) } - }.onFailure { e -> + }.rethrowCancellation().onFailure { e -> Log.e(TAG, "Failed to open new chapter", e) updateState { it.copy(isLoading = false, error = "Failed to load new chapter: ${e.message}") } } diff --git a/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt b/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt new file mode 100644 index 00000000..0afe0fa6 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt @@ -0,0 +1,12 @@ +package io.aatricks.easyreader.util + +import kotlinx.coroutines.CancellationException + +/** + * Rethrows kotlinx.coroutines.CancellationException if present in the Result. + */ +fun Result.rethrowCancellation(): Result { + val e = exceptionOrNull() + if (e is CancellationException) throw e + return this +} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt new file mode 100644 index 00000000..060ae459 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt @@ -0,0 +1,75 @@ +package io.aatricks.easyreader.ui.viewmodel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BaseViewModelTest { + + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + data class TestState( + val isLoading: Boolean = false, + val error: String? = null + ) + + class TestViewModel : BaseViewModel(TestState()) { + fun runWithStatus( + handleLoading: Boolean = true, + handleError: Boolean = true, + block: suspend kotlinx.coroutines.CoroutineScope.() -> Unit + ) { + launchWithStatus( + handleLoading = handleLoading, + handleError = handleError, + loadingState = { state, loading -> state.copy(isLoading = loading) }, + errorState = { state, err -> state.copy(error = err) }, + block = block + ) + } + } + + @Test + fun `cancelled launchWithStatus does not set error state`() = runTest { + val viewModel = TestViewModel() + viewModel.runWithStatus { + throw CancellationException("Cancelled") + } + + // Assert error is not set because of cancellation + assertNull(viewModel.uiState.value.error) + } + + @Test + fun `failing launchWithStatus sets error state`() = runTest { + val viewModel = TestViewModel() + viewModel.runWithStatus { + throw RuntimeException("Real error") + } + + // Assert error state is set on real failure + assertEquals("Real error", viewModel.uiState.value.error) + assertEquals(false, viewModel.uiState.value.isLoading) + } +} From d1d28ae0b1960a22e9697bee00272f4247314d20 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 22:05:36 -0400 Subject: [PATCH 2/5] Fix(library): apply refresh chapter counts via targeted updates per source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshLibraryUpdates snapshotted all rows, spent seconds on network fetches, then bulk-inserted the stale copies with whole-row REPLACE — reverting any reading progress written during the refresh window. It also grouped by baseTitle alone, stamping one source's chapter count onto every source's rows for multi-source series. Group by (baseTitle, sourceName) and write only totalChapters/hasUpdates through targeted UPDATE queries, leaving progress fields untouched. --- .../easyreader/data/local/LibraryDao.kt | 6 + .../data/repository/LibraryRepository.kt | 46 +++-- .../repository/LibraryRepositoryUpdateTest.kt | 194 ++++++++++++++++-- 3 files changed, 213 insertions(+), 33 deletions(-) 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 601bde86..6a9ebb42 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 @@ -52,6 +52,12 @@ interface LibraryDao { @Query("UPDATE library_items SET isDownloaded = :downloaded, downloadedAt = :timestamp WHERE id = :id") suspend fun setDownloaded(id: String, downloaded: Boolean, timestamp: Long?) + @Query("UPDATE library_items SET totalChapters = :totalChapters WHERE id = :id") + suspend fun updateTotalChapters(id: String, totalChapters: Int) + + @Query("UPDATE library_items SET hasUpdates = 1 WHERE id = :id") + suspend fun markHasUpdates(id: String) + @Query("SELECT * FROM library_items WHERE isDownloaded = 1") suspend fun getDownloadedItems(): List 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 89eb250b..1a51dabc 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 @@ -2,6 +2,7 @@ package io.aatricks.easyreader.data.repository import io.aatricks.easyreader.util.TextUtils import io.aatricks.easyreader.util.normalizeChapterList +import io.aatricks.easyreader.util.rethrowCancellation import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.local.LibraryDao import io.aatricks.easyreader.data.model.LibraryItem @@ -82,6 +83,7 @@ class LibraryRepository @Inject constructor( block: suspend () -> T ): T? = withContext(Dispatchers.IO) { kotlin.runCatching { block() } + .rethrowCancellation() .onFailure { e -> Log.e(TAG, errorMessage, e) } .getOrDefault(fallback) } @@ -369,7 +371,11 @@ class LibraryRepository @Inject constructor( ): Unit = io { runRepoCatching("Refresh updates failed") { val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList() - val groupedItems = getGroupedByTitle(allItems) + val groupedItems = allItems.groupBy { item -> + Pair(item.baseTitle.ifBlank { item.title }, item.sourceName) + }.mapValues { (_, group) -> + sortChapters(group) + } val semaphore = Semaphore(5) // Only check for updates on novels that have been read recently or were added recently. @@ -385,19 +391,20 @@ class LibraryRepository @Inject constructor( }) } - val channel = Channel>>(Channel.UNLIMITED) + val channel = Channel, List>>(Channel.UNLIMITED) activeGroups.forEach { channel.trySend(it.key to it.value) } channel.close() val allUpdates = coroutineScope { val workers = (1..5).map { async { - val localUpdates = mutableListOf() - for ((baseTitle, items) in channel) { + val localUpdates = mutableListOf() + for ((key, items) in channel) { + val (baseTitle, _) = key if (items.isNotEmpty()) { val latestInLibrary = items.last() if (latestInLibrary.baseNovelUrl.isNotBlank() && latestInLibrary.sourceName.isNotBlank()) { - val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList()) { + val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList()) { val details = withTimeoutOrNull(REFRESH_PER_SOURCE_TIMEOUT_MS) { exploreRepository.getNovelDetails( latestInLibrary.baseNovelUrl, @@ -415,17 +422,20 @@ class LibraryRepository @Inject constructor( markerChapterNumber >= previousTotalChapters.toDouble() && itemToMark.hasFinishedProgress() items.map { item -> - var newItem = item.copy(totalChapters = sourceChapterCount) - if (wasCaughtUp && item.id == itemToMark.id && !item.hasUpdates) { - newItem = newItem.copy(hasUpdates = true) - } - newItem + val markUpdate = wasCaughtUp && + item.id == itemToMark.id && + !item.hasUpdates + LibraryUpdate( + itemId = item.id, + newTotalChapters = sourceChapterCount, + markHasUpdates = markUpdate + ) } } else { - emptyList() + emptyList() } } else { - emptyList() + emptyList() } } ?: emptyList() localUpdates.addAll(newUpdates) @@ -439,7 +449,12 @@ class LibraryRepository @Inject constructor( } if (allUpdates.isNotEmpty()) { - libraryDao.insertItems(allUpdates) + allUpdates.forEach { update -> + libraryDao.updateTotalChapters(update.itemId, update.newTotalChapters) + if (update.markHasUpdates) { + libraryDao.markHasUpdates(update.itemId) + } + } } } } @@ -473,4 +488,9 @@ class LibraryRepository @Inject constructor( } ?: false } ?: false + private data class LibraryUpdate( + val itemId: String, + val newTotalChapters: Int, + val markHasUpdates: Boolean + ) } diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt index 9e93d35e..86e13781 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt @@ -5,10 +5,13 @@ import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.model.ChapterInfo import io.aatricks.easyreader.data.model.ExploreItem import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.util.FieldUpdate import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -35,6 +38,7 @@ class LibraryRepositoryUpdateTest { fun setup() { MockitoAnnotations.openMocks(this) whenever(preferencesManager.loadLibraryItems()).thenReturn(emptyList()) + whenever(preferencesManager.loadCollapsedSources()).thenReturn(emptySet()) whenever(libraryDao.getAllItems()).thenReturn(flowOf(emptyList())) repository = LibraryRepository(libraryDao, preferencesManager) @@ -118,8 +122,11 @@ class LibraryRepositoryUpdateTest { // Execute repository.refreshLibraryUpdates(exploreRepository) - // Verify behavior: Optimized behavior calls insertItems once with all updates. - verify(libraryDao, times(1)).insertItems(any()) + // Verify behavior: Optimized behavior calls updateTotalChapters. + verify(libraryDao).updateTotalChapters("1", 15) + verify(libraryDao).updateTotalChapters("2", 25) + verify(libraryDao).updateTotalChapters("3", 35) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -142,9 +149,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) - verify(libraryDao).insertItems(argThat { - size == 1 && first().totalChapters == 11 && !first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("unfinished", 11) + verify(libraryDao, never()).markHasUpdates(any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -167,9 +174,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) - verify(libraryDao).insertItems(argThat { - size == 1 && first().totalChapters == 11 && first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("caught-up", 11) + verify(libraryDao).markHasUpdates("caught-up") + verify(libraryDao, never()).insertItems(any()) } @Test @@ -189,9 +196,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) // Verify behavior: It should still update the successful one - verify(libraryDao, times(1)).insertItems(argThat { - size == 1 && first().id == "1" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("1", 15) + verify(libraryDao, never()).updateTotalChapters(eq("2"), any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -230,9 +237,9 @@ class LibraryRepositoryUpdateTest { verify(exploreRepository, never()).getNovelDetails("novel_old", "Source1") // Verify that only recent items were updated in DB - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "recent" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("recent", 15) + verify(libraryDao, never()).updateTotalChapters(eq("old"), any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -263,9 +270,8 @@ class LibraryRepositoryUpdateTest { verify(exploreRepository).getNovelDetails("novel_old_reading", "Source1") // Verify that it was updated - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "old_but_reading" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("old_but_reading", 15) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -297,8 +303,156 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository, ignoreActivityThreshold = true) verify(exploreRepository).getNovelDetails("novel_imported", "Source1") - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "imported" && first().totalChapters == 15 && first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("imported", 15) + verify(libraryDao).markHasUpdates("imported") + verify(libraryDao, never()).insertItems(any()) + } + + @Test + fun `refresh preserves progress written during the refresh window`() = runBlocking { + val item = LibraryItem( + id = "1", title = "Novel 1", url = "url1", + baseTitle = "Novel 1", baseNovelUrl = "novel1", sourceName = "Source1", + currentChapter = "Ch 10", + totalChapters = 10, + progress = 50, + lastReadElementKey = "oldKey" + ) + val dbItems = mutableMapOf("1" to item) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.getItemById("1")).thenAnswer { dbItems["1"] } + whenever(libraryDao.insertItem(any())).thenAnswer { inv -> + val itm = inv.arguments[0] as LibraryItem + dbItems[itm.id] = itm + Unit + } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + + // We want getNovelDetails to suspend, allowing us to update progress concurrently + val deferredNovelDetails = kotlinx.coroutines.CompletableDeferred() + whenever(exploreRepository.getNovelDetails("novel1", "Source1")).thenAnswer { + runBlocking { deferredNovelDetails.await() } + } + + val refreshJob = launch { + repository.refreshLibraryUpdates(exploreRepository) + } + + // yield to let refreshLibraryUpdates start and wait on getNovelDetails + kotlinx.coroutines.yield() + + // updateProgressExplicit concurrently + repository.updateProgressExplicit( + itemId = "1", + progress = FieldUpdate.Set(80), + lastReadElementKey = FieldUpdate.Set("newKey") + ) + + // Now resume getNovelDetails with more chapters + val chapters = chapterList(15, "novel1") + deferredNovelDetails.complete(ExploreItem("Novel 1", "novel1", source = "Source1", chapters = chapters)) + + refreshJob.join() + + val finalItem = dbItems["1"]!! + assertEquals(80, finalItem.progress) + assertEquals("newKey", finalItem.lastReadElementKey) + assertEquals(15, finalItem.totalChapters) + } + + @Test + fun `refresh keeps per-source chapter counts independent`() = runBlocking { + val item1 = LibraryItem( + id = "1", title = "Novel", url = "url1", + baseTitle = "Novel", baseNovelUrl = "novel_src1", sourceName = "Source1", + totalChapters = 10 + ) + val item2 = LibraryItem( + id = "2", title = "Novel", url = "url2", + baseTitle = "Novel", baseNovelUrl = "novel_src2", sourceName = "Source2", + totalChapters = 10 + ) + val dbItems = mutableMapOf("1" to item1, "2" to item2) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + + whenever(exploreRepository.getNovelDetails("novel_src1", "Source1")) + .thenReturn(ExploreItem("Novel", "novel_src1", source = "Source1", chapters = chapterList(15, "novel_src1"))) + whenever(exploreRepository.getNovelDetails("novel_src2", "Source2")) + .thenReturn(ExploreItem("Novel", "novel_src2", source = "Source2", chapters = chapterList(20, "novel_src2"))) + + repository.refreshLibraryUpdates(exploreRepository) + + assertEquals(15, dbItems["1"]?.totalChapters) + assertEquals(20, dbItems["2"]?.totalChapters) + } + + @Test + fun `refresh marks hasUpdates only on the caught-up marker item`() = runBlocking { + // Group of 3 items under same baseTitle, same sourceName. + // One is currently reading (item2), one is older (item1), one is newer but not read (item3). + val item1 = LibraryItem( + id = "1", title = "Novel Ch 5", url = "url1", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 5", totalChapters = 10, progress = 100, hasUpdates = false + ) + val item2 = LibraryItem( + id = "2", title = "Novel Ch 10", url = "url10", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 10", totalChapters = 10, progress = 90, hasUpdates = false, + isCurrentlyReading = true + ) + val item3 = LibraryItem( + id = "3", title = "Novel Ch 10", url = "url3", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 10", totalChapters = 10, progress = 0, hasUpdates = false + ) + val dbItems = mutableMapOf("1" to item1, "2" to item2, "3" to item3) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + whenever(libraryDao.markHasUpdates(any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + dbItems[id]?.let { + dbItems[id] = it.copy(hasUpdates = true) + } + Unit + } + + whenever(exploreRepository.getNovelDetails("novel", "Source1")) + .thenReturn(ExploreItem("Novel", "novel", source = "Source1", chapters = chapterList(12, "novel"))) + + repository.refreshLibraryUpdates(exploreRepository) + + // item2 is currently reading and caught up (progress 90 >= 90% of totalChapters 10). + // It should be marked with hasUpdates. + // item1 and item3 should NOT be marked. + assertTrue(dbItems["2"]!!.hasUpdates) + assertFalse(dbItems["1"]!!.hasUpdates) + assertFalse(dbItems["3"]!!.hasUpdates) + + assertEquals(12, dbItems["1"]!!.totalChapters) + assertEquals(12, dbItems["2"]!!.totalChapters) + assertEquals(12, dbItems["3"]!!.totalChapters) } } From dc3575e51e37f4f0c97ac77dfb3628fb0209fb9c Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 22:05:46 -0400 Subject: [PATCH 3/5] Fix(reader): recapture edge blur via snapshotFlow to stop per-frame recomposition The recapture effect used listState.firstVisibleItemScrollOffset (and index/ currentPage) as LaunchedEffect keys, so composition read the scroll offset directly: every scrolled pixel recomposed the whole ContentArea and cancelled/relaunched the effect, even with controls hidden. Read the values inside a snapshotFlow instead; collectLatest preserves the settle debounce. --- .../ui/screens/reader/ReaderContentArea.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 7329b275..34b446cc 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 @@ -58,6 +58,7 @@ import io.aatricks.easyreader.ui.components.applyReaderEdgeBlur import io.aatricks.easyreader.ui.components.supportsReaderEdgeBlur import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.abs @@ -134,15 +135,18 @@ internal fun ContentArea( if (uiState.showControls) edgeBlurCaptureGeneration++ } - LaunchedEffect( - uiState.showControls, - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset, - pagerState.currentPage - ) { + LaunchedEffect(uiState.showControls, content.url) { if (!uiState.showControls) return@LaunchedEffect - kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS) - edgeBlurCaptureGeneration++ + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + pagerState.currentPage + ) + }.collectLatest { + kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS) + edgeBlurCaptureGeneration++ + } } LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) { From c0f08762ae1f2a7b38f0ef763d1552f25de9a537 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 22:05:46 -0400 Subject: [PATCH 4/5] Fix(reader): persist sheet visibility across process death Chapter list, settings, and the Cloudflare dialog used plain remember, so process death while open silently closed them. Use rememberSaveable. --- .../easyreader/ui/screens/reader/ReaderScreen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index d8873c76..ee720c44 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -77,11 +78,11 @@ fun ReaderScreen( val scope = rememberCoroutineScope() val context = LocalContext.current - var showCloudflareWebView by remember { mutableStateOf(false) } - var cloudflareUrl by remember { mutableStateOf("") } + var showCloudflareWebView by rememberSaveable { mutableStateOf(false) } + var cloudflareUrl by rememberSaveable { mutableStateOf("") } - var showChapterList by remember { mutableStateOf(false) } - var showSettings by remember { mutableStateOf(false) } + var showChapterList by rememberSaveable { mutableStateOf(false) } + var showSettings by rememberSaveable { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState() val settingsSheetState = rememberModalBottomSheetState() From 2b38161419e8d75291be4911b52e1f94da361dcd Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 1 Jul 2026 22:05:46 -0400 Subject: [PATCH 5/5] Fix(cache): write chapter-list cache entries atomically save() wrote directly to the target file; a crash or concurrent save could leave corrupt JSON (load degrades to a refetch, but avoidably). Write to a temp file and rename, mirroring WebOfflineChapterStore.writeManifest. --- .../data/repository/ChapterListCache.kt | 10 ++- .../data/repository/ChapterListCacheTest.kt | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt index 5beb2713..c7015c8d 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt @@ -52,7 +52,15 @@ class ChapterListCache @Inject constructor( baseNovelUrl = baseNovelUrl, sourceName = sourceName ) - file.writeText(json.encodeToString(Entry.serializer(), entry)) + val temp = File.createTempFile("${file.name}.", ".tmp", file.parentFile) + try { + temp.writeText(json.encodeToString(Entry.serializer(), entry)) + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + } + } finally { + temp.delete() + } } } diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt new file mode 100644 index 00000000..e4875508 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt @@ -0,0 +1,66 @@ +package io.aatricks.easyreader.data.repository + +import io.aatricks.easyreader.data.model.ChapterInfo +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class ChapterListCacheTest { + + private lateinit var tempDir: File + private lateinit var cache: ChapterListCache + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun setup() { + tempDir = Files.createTempDirectory("chapter_list_cache_test").toFile() + cache = ChapterListCache(tempDir, json) + } + + @After + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun `save and load round trip`() { + val chapters = listOf( + ChapterInfo("Ch 1", "url1"), + ChapterInfo("Ch 2", "url2") + ) + + cache.save("novel1", "Source1", chapters) + + val loaded = cache.load("novel1", "Source1") + assertNotNull(loaded) + assertEquals(chapters, loaded?.chapters) + assertEquals("novel1", loaded?.baseNovelUrl) + assertEquals("Source1", loaded?.sourceName) + } + + @Test + fun `save over existing entry round trip`() { + val chapters1 = listOf(ChapterInfo("Ch 1", "url1")) + val chapters2 = listOf( + ChapterInfo("Ch 1", "url1"), + ChapterInfo("Ch 2", "url2") + ) + + // First save + cache.save("novel1", "Source1", chapters1) + val loaded1 = cache.load("novel1", "Source1") + assertNotNull(loaded1) + assertEquals(chapters1, loaded1?.chapters) + + // Save over existing + cache.save("novel1", "Source1", chapters2) + val loaded2 = cache.load("novel1", "Source1") + assertNotNull(loaded2) + assertEquals(chapters2, loaded2?.chapters) + } +}