diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ReaderTestEnvironment.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ReaderTestEnvironment.kt new file mode 100644 index 0000000000..c65d3c09cc --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ReaderTestEnvironment.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.reader + +import android.content.Context +import androidx.work.Configuration +import androidx.work.WorkManager + +/** HiltTestApplication does not implement the production application's WorkManager configuration. */ +internal fun initializeReaderTestWorkManager(context: Context) { + try { + WorkManager.getInstance(context) + } catch (_: IllegalStateException) { + WorkManager.initialize(context, Configuration.Builder().build()) + } +} diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/SmartResumeReaderTest.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/SmartResumeReaderTest.kt new file mode 100644 index 0000000000..ceba144a1c --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/SmartResumeReaderTest.kt @@ -0,0 +1,224 @@ +package io.github.landwarderer.futon.reader + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.lifecycle.ViewModelProvider +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.landwarderer.futon.SampleData +import io.github.landwarderer.futon.core.cache.MemoryContentCache +import io.github.landwarderer.futon.core.cache.SafeDeferred +import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.TestMangaSource +import io.github.landwarderer.futon.core.nav.ReaderIntent +import io.github.landwarderer.futon.core.parser.MangaDataRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.ReaderMode +import io.github.landwarderer.futon.history.data.HistoryRepository +import io.github.landwarderer.futon.reader.ui.ReaderActivity +import io.github.landwarderer.futon.reader.ui.ReaderState +import io.github.landwarderer.futon.reader.ui.ReaderViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import java.io.File +import javax.inject.Inject + +/** Exercise the actual Activity/ViewModel/history path with cached metadata and local test images. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SmartResumeReaderTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @Inject lateinit var cache: MemoryContentCache + @Inject lateinit var data: MangaDataRepository + @Inject lateinit var history: HistoryRepository + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + + @Before + fun setUp() { + initializeReaderTestWorkManager(context) + hiltRule.inject() + cache.clear(TestMangaSource) + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(AppSettings.KEY_SMART_RESUME, true) + .putString(AppSettings.KEY_SMART_RESUME_MODE, "PERCENTAGE") + .putString(AppSettings.KEY_SMART_RESUME_PERCENTAGE, "90") + .putBoolean(AppSettings.KEY_SMART_RESUME_WAIT, false) + .commit() + } + + @Test + fun cachedResumeInStandardAndWebtoonReaders() = runBlocking { + for (mode in listOf(ReaderMode.STANDARD, ReaderMode.WEBTOON)) { + val fixture = fixture(mode, cachedSuccessor = true) + launch(fixture).use { scenario -> + val model = model(scenario) + awaitChapter(model, 30) + if (mode == ReaderMode.STANDARD) assertEquals(0, model.readingState.value?.page) + assertEquals(mode, model.readerMode.value) + assertEquals(30L, history.getOne(fixture.updated)?.chapterId) + delay(1000) + screenshot("smart-resume-${mode.name.lowercase()}") + } + } + } + + @Test + fun cachedModeDoesNotJumpWhenDetailsArriveLater() = runBlocking { + val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true) + launch(fixture).use { scenario -> + val model = model(scenario) + awaitChapter(model, 29) + fixture.update.complete(Result.success(fixture.updated)) + withTimeout(30_000) { model.mangaDetails.first { it?.isLoaded == true } } + assertEquals(29L, model.readingState.value?.chapterId) + } + } + + @Test + fun waitingDefersContentAndHistoryUntilUpdate() = runBlocking { + setWaiting() + val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true) + launch(fixture).use { scenario -> + val model = model(scenario) + withTimeout(30_000) { model.readerMode.first { it != null } } + assertNull(model.readingState.value) + assertTrue(model.content.value.pages.isEmpty()) + assertEquals(29, history.getOne(fixture.saved)?.chaptersCount) + fixture.update.complete(Result.success(fixture.updated)) + awaitChapter(model, 30) + assertEquals(30L, history.getOne(fixture.updated)?.chapterId) + } + } + + @Test + fun failedUpdateReleasesSavedChapter() = runBlocking { + setWaiting() + val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = false, deferUpdate = true) + launch(fixture).use { scenario -> + val model = model(scenario) + withTimeout(30_000) { model.readerMode.first { it != null } } + fixture.update.complete(Result.failure(IllegalStateException("Test update failed"))) + awaitChapter(model, 29) + assertEquals(13, model.readingState.value?.page) + } + } + + @Test + fun explicitPositionAndActivityRecreationStayExact() = runBlocking { + val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = true) + launch(fixture, ReaderState(29, 13, 0)).use { scenario -> + awaitChapter(model(scenario), 29) + scenario.recreate() + val model = model(scenario) + awaitChapter(model, 29) + assertEquals(13, model.readingState.value?.page) + } + } + + @Test + fun incognitoResumeDoesNotWriteHistory() = runBlocking { + val fixture = fixture(ReaderMode.STANDARD, cachedSuccessor = true) + launch(fixture, incognito = true).use { scenario -> + awaitChapter(model(scenario), 30) + assertEquals(29L, history.getOne(fixture.updated)?.chapterId) + } + } + + private fun setWaiting() { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(AppSettings.KEY_SMART_RESUME_WAIT, true).commit() + } + + private suspend fun fixture(mode: ReaderMode, cachedSuccessor: Boolean, deferUpdate: Boolean = false): Fixture { + val id = -System.nanoTime() + val chapters = (1L..30L).map { chapterId -> + MangaChapter( + id = chapterId, title = "Chapter $chapterId", number = chapterId.toFloat(), volume = 0, + url = "https://example.invalid/$id/$chapterId", uploadDate = 0, + scanlator = null, branch = null, source = TestMangaSource, + ) + } + // Include the previous chapter for normal backward metadata loading near the boundary. + for (chapter in chapters.takeLast(3)) { + val pages = (1..15).map { pageNumber -> + val image = File(context.cacheDir, "reader-test-${chapter.id}-$pageNumber.png") + val bitmap = Bitmap.createBitmap(600, 900, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(if (chapter.id == 30L) Color.rgb(205, 235, 220) else Color.rgb(220, 225, 245)) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; textSize = 38f } + canvas.drawText("Chapter ${chapter.id} / Page $pageNumber", 30f, 100f, paint) + image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + MangaPage(chapter.id * 100 + pageNumber, image.toURI().toString(), null, LocalMangaSource) + } + cache.putPages(TestMangaSource, chapter.url, SafeDeferred(CompletableDeferred(Result.success(pages)))) + } + val updated = SampleData.mangaDetails.copy( + id = id, title = "Reader resume test", source = TestMangaSource, + url = "https://example.invalid/$id", publicUrl = "https://example.invalid/$id", + chapters = chapters, tags = emptySet(), contentRating = ContentRating.SAFE, + description = null, coverUrl = "", largeCoverUrl = null, + ) + val saved = updated.copy(chapters = chapters.take(29)) + history.addOrUpdate(saved, 29, 13, 0, 1f, force = true) + data.saveReaderMode(saved, mode) + if (cachedSuccessor) data.storeManga(updated, replaceExisting = true) + val update = CompletableDeferred>() + if (!deferUpdate) update.complete(Result.success(updated)) + cache.putDetails(TestMangaSource, updated.url, SafeDeferred(update)) + return Fixture(saved, updated, update) + } + + private fun launch(fixture: Fixture, state: ReaderState? = null, incognito: Boolean = false): ActivityScenario { + val builder = ReaderIntent.Builder(context).mangaId(fixture.saved.id) + if (state != null) builder.state(state) + if (incognito) builder.incognito() + return ActivityScenario.launch(builder.build().intent) + } + + private fun model(scenario: ActivityScenario): ReaderViewModel { + lateinit var model: ReaderViewModel + scenario.onActivity { model = ViewModelProvider(it)[ReaderViewModel::class.java] } + return model + } + + private suspend fun awaitChapter(model: ReaderViewModel, id: Long) { + withTimeout(30_000) { + combine(model.content, model.readingState) { content, state -> + state?.chapterId == id && content.pages.any { it.chapterId == id } + }.first { it } + } + } + + private fun screenshot(name: String) { + val image = instrumentation.uiAutomation.takeScreenshot() ?: return + File(context.getExternalFilesDir(null), "$name.png").outputStream().use { + image.compress(Bitmap.CompressFormat.PNG, 100, it) + } + image.recycle() + } + + private data class Fixture(val saved: Manga, val updated: Manga, val update: CompletableDeferred>) +} diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/SmartResumeSettingsTest.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/SmartResumeSettingsTest.kt new file mode 100644 index 0000000000..14187c6dfc --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/SmartResumeSettingsTest.kt @@ -0,0 +1,156 @@ +package io.github.landwarderer.futon.settings + +import android.content.Intent +import android.view.View +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import androidx.preference.SwitchPreferenceCompat +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode +import io.github.landwarderer.futon.reader.initializeReaderTestWorkManager +import io.github.landwarderer.futon.settings.reader.SmartResumeThresholdDialog +import io.github.landwarderer.futon.settings.search.SettingsSearchHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SmartResumeSettingsTest { + @get:Rule val hiltRule = HiltAndroidRule(this) + @Inject lateinit var settings: AppSettings + @Inject lateinit var searchHelper: SettingsSearchHelper + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Before + fun setUp() { + initializeReaderTestWorkManager(instrumentation.targetContext) + hiltRule.inject() + PreferenceManager.getDefaultSharedPreferences(instrumentation.targetContext).edit() + .remove(AppSettings.KEY_SMART_RESUME) + .remove(AppSettings.KEY_SMART_RESUME_MODE) + .remove(AppSettings.KEY_SMART_RESUME_PERCENTAGE) + .remove(AppSettings.KEY_SMART_RESUME_PAGES) + .remove(AppSettings.KEY_SMART_RESUME_WAIT) + .commit() + } + + @Test + fun defaultsDependenciesAndIndependentValuesSurviveRecreation() { + launch().use { scenario -> + scenario.onActivity { activity -> + val fragment = readerSettings(activity) + assertFalse(settings.isSmartResumeEnabled) + assertFalse(settings.isSmartResumeWaitForUpdates) + assertEquals(90, settings.smartResumePercentage) + assertEquals(1, settings.smartResumePagesRemaining) + val mode = fragment.findPreference(AppSettings.KEY_SMART_RESUME_MODE)!! + val percentage = fragment.findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)!! + val pages = fragment.findPreference(AppSettings.KEY_SMART_RESUME_PAGES)!! + assertFalse(mode.isEnabled) + assertTrue(percentage.isVisible) + assertFalse(pages.isVisible) + fragment.findPreference(AppSettings.KEY_SMART_RESUME)!!.isChecked = true + assertTrue(mode.isEnabled) + percentage.text = "95" + mode.value = ChapterCompletionMode.PAGES_REMAINING.name + assertFalse(percentage.isVisible) + assertTrue(pages.isVisible) + pages.text = "2" + mode.value = ChapterCompletionMode.PERCENTAGE.name + assertEquals("95", percentage.text) + assertEquals("2", pages.text) + } + scenario.recreate() + scenario.onActivity { + assertTrue(settings.isSmartResumeEnabled) + assertEquals(95, settings.smartResumePercentage) + assertEquals(2, settings.smartResumePagesRemaining) + } + } + } + + @Test + fun numericDialogRejectsInvalidValuesWithoutClosing() { + launch().use { scenario -> + scenario.onActivity { activity -> + val fragment = readerSettings(activity) + fragment.findPreference(AppSettings.KEY_SMART_RESUME)!!.isChecked = true + fragment.onDisplayPreferenceDialog( + fragment.findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)!!, + ) + } + instrumentation.waitForIdleSync() + scenario.onActivity { activity -> + val dialog = activity.supportFragmentManager + .findFragmentByTag("androidx.preference.PreferenceFragment.DIALOG") as SmartResumeThresholdDialog + val alert = dialog.requireDialog() as AlertDialog + val editor = alert.findViewById(android.R.id.edit)!! + for (invalid in listOf("", "0", "101", "-1", "1.5", "2147483648")) { + editor.setText(invalid) + alert.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + assertTrue(alert.isShowing) + assertNotNull(editor.error) + assertEquals(90, settings.smartResumePercentage) + } + editor.setText("91") + alert.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + assertEquals(91, settings.smartResumePercentage) + } + } + } + + @Test + fun helpButtonIsAccessibleAndDoesNotToggleWaiting() { + launch().use { scenario -> + scenario.onActivity { readerSettings(it).scrollToPreference(AppSettings.KEY_SMART_RESUME_WAIT) } + instrumentation.waitForIdleSync() + scenario.onActivity { activity -> + val help = activity.findViewById(R.id.preference_help) + assertNotNull(help) + assertTrue(help.isEnabled) + assertFalse(help.contentDescription.isNullOrBlank()) + help.performClick() + assertFalse(settings.isSmartResumeWaitForUpdates) + } + } + } + + @Test + fun thresholdSearchTargetsVisibleParentInBothModes() { + for (mode in ChapterCompletionMode.entries) { + PreferenceManager.getDefaultSharedPreferences(instrumentation.targetContext).edit() + .putString(AppSettings.KEY_SMART_RESUME_MODE, mode.name).commit() + val results = searchHelper.inflatePreferences().filter { + it.title == instrumentation.targetContext.getString(R.string.smart_resume_threshold) + } + assertEquals(1, results.size) + assertEquals(AppSettings.KEY_SMART_RESUME, results.single().key) + } + } + + private fun launch(): ActivityScenario = ActivityScenario.launch( + Intent(instrumentation.targetContext, SettingsActivity::class.java).setAction(AppRouter.ACTION_READER), + ) + + private fun readerSettings(activity: SettingsActivity) = + activity.supportFragmentManager.findFragmentById(R.id.container) as ReaderSettingsFragment +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index 99fb3347ab..f5a2582aaf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -530,6 +530,23 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getBoolean(KEY_READER_AUTOSCROLL_FAB, true) set(value) = prefs.edit { putBoolean(KEY_READER_AUTOSCROLL_FAB, value) } + val isSmartResumeEnabled: Boolean + get() = prefs.getBoolean(KEY_SMART_RESUME, false) + + val smartResumeCompletionMode: ChapterCompletionMode + get() = ChapterCompletionMode.from(prefs.getString(KEY_SMART_RESUME_MODE, null)) + + val smartResumePercentage: Int + get() = prefs.getString(KEY_SMART_RESUME_PERCENTAGE, null)?.toIntOrNull() + ?.takeIf { it in 1..100 } ?: 90 + + val smartResumePagesRemaining: Int + get() = prefs.getString(KEY_SMART_RESUME_PAGES, null)?.toIntOrNull() + ?.takeIf { it >= 0 } ?: 1 + + val isSmartResumeWaitForUpdates: Boolean + get() = prefs.getBoolean(KEY_SMART_RESUME_WAIT, false) + val isPagesPreloadEnabled: Boolean get() { if (isBackgroundNetworkRestricted()) { @@ -780,6 +797,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_PAGES_PRELOAD = "pages_preload" + const val KEY_SMART_RESUME = "reader_smart_resume" + const val KEY_SMART_RESUME_MODE = "reader_smart_resume_mode" + const val KEY_SMART_RESUME_PERCENTAGE = "reader_smart_resume_percentage" + const val KEY_SMART_RESUME_PAGES = "reader_smart_resume_pages" + const val KEY_SMART_RESUME_WAIT = "reader_smart_resume_wait" const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ChapterCompletionMode.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ChapterCompletionMode.kt new file mode 100644 index 0000000000..af2437d713 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ChapterCompletionMode.kt @@ -0,0 +1,10 @@ +package io.github.landwarderer.futon.core.prefs + +enum class ChapterCompletionMode { + PERCENTAGE, + PAGES_REMAINING; + + companion object { + fun from(value: String?): ChapterCompletionMode = entries.find { it.name == value } ?: PERCENTAGE + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/ChaptersLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/ChaptersLoader.kt index 0fa5bffe27..3bade44903 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/ChaptersLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/ChaptersLoader.kt @@ -1,6 +1,6 @@ package io.github.landwarderer.futon.reader.domain -import android.util.LongSparseArray +import androidx.collection.LongSparseArray import androidx.annotation.CheckResult import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.sync.Mutex @@ -60,8 +60,9 @@ class ChaptersLoader @Inject constructor( return true } - suspend fun loadSingleChapter(chapterId: Long): Boolean { + suspend fun loadSingleChapter(chapterId: Long, keepCurrentOnEmpty: Boolean = false): Boolean { val pages = loadChapter(chapterId) + if (keepCurrentOnEmpty && pages.isEmpty()) return false return mutex.withLock { chapterPages.clear() chapterPages.addLast(chapterId, pages) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolver.kt new file mode 100644 index 0000000000..0d60109d97 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolver.kt @@ -0,0 +1,58 @@ +package io.github.landwarderer.futon.reader.domain + +import io.github.landwarderer.futon.core.model.MangaHistory +import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode +import io.github.landwarderer.futon.reader.ui.ReaderState +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable + +internal data class ChapterCompletionRule( + val mode: ChapterCompletionMode, + val percentage: Int, + val pagesRemaining: Int, +) { + fun isCompleted(page: Int, pageCount: Int): Boolean { + if (pageCount <= 0 || page !in 0 until pageCount) return false + return when (mode) { + ChapterCompletionMode.PERCENTAGE -> percentage in 1..100 && + (page.toLong() + 1) * 100 >= percentage.toLong() * pageCount + ChapterCompletionMode.PAGES_REMAINING -> pagesRemaining >= 0 && + pageCount.toLong() - page - 1 <= pagesRemaining + } + } +} + +/** One decision per reader opening, using history captured before any startup writes or recovery. */ +internal class SmartResumeResolver( + private val originalState: ReaderState, + private val history: MangaHistory?, + private val rule: ChapterCompletionRule, + private val waitForUpdates: Boolean, +) { + private var resolvedState: ReaderState? = null + + suspend fun resolve( + branchChapterIds: List, + savedPageCount: Int, + updatesFinished: Boolean, + loadNext: suspend (Long) -> Boolean, + ): ReaderState? { + resolvedState?.let { return it } + val saved = history + val index = branchChapterIds.indexOf(originalState.chapterId) + val candidate = saved != null && saved.chapterId == originalState.chapterId && + saved.chaptersCount > 0 && index == saved.chaptersCount - 1 && + rule.isCompleted(saved.page, savedPageCount) + if (!candidate) return finish(originalState) + if (waitForUpdates && !updatesFinished) return null + val nextId = branchChapterIds.getOrNull(index + 1) ?: return finish(originalState) + val loaded = runCatchingCancellable { loadNext(nextId) }.getOrDefault(false) + return finish(if (loaded) ReaderState(nextId, 0, 0) else originalState) + } + + private fun finish(state: ReaderState): ReaderState { + resolvedState = state + return state + } + + fun fallback(): ReaderState = resolvedState ?: finish(originalState) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt index 2d60274ee8..5b1ebc4797 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderViewModel.kt @@ -12,6 +12,7 @@ import io.github.landwarderer.futon.bookmarks.domain.Bookmark import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository import io.github.landwarderer.futon.core.exceptions.EmptyMangaException import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.MangaHistory import io.github.landwarderer.futon.core.model.getPreferredBranch import io.github.landwarderer.futon.core.nav.MangaIntent import io.github.landwarderer.futon.core.nav.ReaderIntent @@ -40,6 +41,8 @@ import io.github.landwarderer.futon.local.data.LocalStorageChanges import io.github.landwarderer.futon.local.domain.DeleteLocalMangaUseCase import io.github.landwarderer.futon.local.domain.model.LocalManga import io.github.landwarderer.futon.reader.domain.ChaptersLoader +import io.github.landwarderer.futon.reader.domain.ChapterCompletionRule +import io.github.landwarderer.futon.reader.domain.SmartResumeResolver import io.github.landwarderer.futon.reader.domain.DetectReaderModeUseCase import io.github.landwarderer.futon.reader.domain.PageLoader import io.github.landwarderer.futon.reader.ui.config.ReaderSettings @@ -446,9 +449,20 @@ class ReaderViewModel @Inject constructor( } private fun loadImpl() { + val smartResumeEnabled = settings.isSmartResumeEnabled + val waitForUpdates = settings.isSmartResumeWaitForUpdates + val completionRule = ChapterCompletionRule( + settings.smartResumeCompletionMode, + settings.smartResumePercentage, + settings.smartResumePagesRemaining, + ) loadingJob = launchLoadingJob(Dispatchers.IO + EventExceptionHandler(onLoadingError)) { var exception: Throwable? = null var loadedDetails: MangaDetails? = null + var initialStart: InitialReaderStart? = null + var resumeResolver: SmartResumeResolver? = null + var deferredDetails: MangaDetails? = null + var savedPageCount = 0 try { detailsLoadUseCase(intent, force = false) .collect { details -> @@ -467,24 +481,42 @@ class ReaderViewModel @Inject constructor( val manga = details.toManga() // obtain state if (readingState.value == null) { - val newState = getStateFromIntent(manga) - if (newState == null) { - return@collect // manga not loaded yet if cannot get state + val start = initialStart ?: getStateFromIntent(manga)?.also { initialStart = it } + ?: return@collect + val chapter = chaptersLoader.peekChapter(start.state.chapterId) ?: return@collect + if (resumeResolver == null) { + val mode = runCatchingCancellable { + detectReaderModeUseCase(manga, start.state) + }.getOrDefault(settings.defaultReaderMode) + selectedBranch.value = chapter.branch + readerMode.value = mode + try { + check(chaptersLoader.loadSingleChapter(start.state.chapterId)) { + "Chapter contains no pages" + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + exception = e.mergeWith(exception) + return@collect + } + savedPageCount = chaptersLoader.getPagesCount(start.state.chapterId) + resumeResolver = SmartResumeResolver( + start.state, + start.history.takeIf { smartResumeEnabled }, + completionRule, + waitForUpdates, + ) } + deferredDetails = details + val newState = resumeResolver?.resolve( + branchChapterIds = details.chapters[chapter.branch].orEmpty().map { it.id }, + savedPageCount = savedPageCount, + updatesFinished = details.isLoaded, + loadNext = { chaptersLoader.loadSingleChapter(it, keepCurrentOnEmpty = true) }, + ) ?: return@collect readingState.value = newState - val mode = runCatchingCancellable { - detectReaderModeUseCase(manga, newState) - }.getOrDefault(settings.defaultReaderMode) - val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch - selectedBranch.value = branch - readerMode.value = mode - try { - chaptersLoader.loadSingleChapter(newState.chapterId) - } catch (e: Throwable) { - readingState.value = null // try next time - exception = e.mergeWith(exception) - return@collect - } + deferredDetails = null } else if (!wasCurrentChapterLocal && isCurrentChapterLocal) { readingState.value?.let { runCatchingCancellable { @@ -494,23 +526,30 @@ class ReaderViewModel @Inject constructor( } } } - mangaDetails.value = details.filterChapters(selectedBranch.value) - - // save state - if (!isIncognitoMode.firstNotNull()) { - readingState.value?.let { - val percent = computePercent(it.chapterId, it.page) - historyUpdateUseCase(manga, it, percent) - } - } - notifyStateChanged() - content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) + publishReaderContent(details) } } catch (e: CancellationException) { throw e } catch (e: Throwable) { exception = e.mergeWith(exception) } + // A details flow may finish (or fail) without an isLoaded result. Release a deferred resume. + val deferred = deferredDetails + val resolver = resumeResolver + if (readingState.value == null && deferred != null && resolver != null) { + chaptersLoader.init(deferred) + readingState.value = if (exception != null) { + resolver.fallback() + } else { + resolver.resolve( + branchChapterIds = deferred.chapters[selectedBranch.value].orEmpty().map { it.id }, + savedPageCount = savedPageCount, + updatesFinished = true, + loadNext = { chaptersLoader.loadSingleChapter(it, keepCurrentOnEmpty = true) }, + ) + } + publishReaderContent(deferred) + } if (readingState.value == null) { val loadedManga = loadedDetails // for smart cast if (loadedManga != null) { @@ -541,6 +580,17 @@ class ReaderViewModel @Inject constructor( } } + private suspend fun publishReaderContent(details: MangaDetails) { + mangaDetails.value = details.filterChapters(selectedBranch.value) + if (!isIncognitoMode.firstNotNull()) { + readingState.value?.let { + historyUpdateUseCase(details.toManga(), it, computePercent(it.chapterId, it.page)) + } + } + notifyStateChanged() + content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) + } + @AnyThread private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { val prevJob = loadingJob @@ -633,7 +683,7 @@ class ReaderViewModel @Inject constructor( } } - private suspend fun getStateFromIntent(manga: Manga): ReaderState? { + private suspend fun getStateFromIntent(manga: Manga): InitialReaderStart? { // check if we have at least some chapters loaded if (manga.chapters.isNullOrEmpty()) { return null @@ -642,7 +692,7 @@ class ReaderViewModel @Inject constructor( val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE] if (requestedState != null) { return if (manga.findChapterById(requestedState.chapterId) != null) { - requestedState + InitialReaderStart(requestedState) } else { null } @@ -650,26 +700,34 @@ class ReaderViewModel @Inject constructor( val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH] // continue reading - val history = historyRepository.getOne(manga) + val originalHistory = historyRepository.observeOne(manga.id).first() + val originalChapter = originalHistory?.let { manga.findChapterById(it.chapterId) } + val history = if (originalChapter != null) originalHistory else historyRepository.getOne(manga) if (history != null) { val chapter = manga.findChapterById(history.chapterId) ?: return null // specified branch is requested return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) { if (chapter.branch == requestedBranch) { - ReaderState(history) + InitialReaderStart(ReaderState(history), originalHistory.takeIf { originalChapter != null }) } else { - ReaderState(manga, requestedBranch) + InitialReaderStart(ReaderState(manga, requestedBranch)) } } else { - ReaderState(history) + InitialReaderStart(ReaderState(history), originalHistory.takeIf { originalChapter != null }) } } // start from beginning val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null) - return ReaderState(manga, preferredBranch) + return InitialReaderStart(ReaderState(manga, preferredBranch)) } + private data class InitialReaderStart( + val state: ReaderState, + // Only an unrecovered, implicit history resume carries eligibility evidence. + val history: MangaHistory? = null, + ) + private fun Throwable.mergeWith(other: Throwable?): Throwable = if (other == null) { this } else { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt index fc92b71ceb..44ccf27251 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt @@ -5,6 +5,9 @@ import android.content.pm.ActivityInfo import android.os.Bundle import android.view.View import androidx.preference.ListPreference +import androidx.preference.EditTextPreference +import androidx.core.os.bundleOf +import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint @@ -12,6 +15,9 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.ZoomMode import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode +import io.github.landwarderer.futon.settings.reader.SmartResumeThresholdDialog +import io.github.landwarderer.futon.settings.utils.HelpSwitchPreference import io.github.landwarderer.futon.core.prefs.ReaderAnimation import io.github.landwarderer.futon.core.prefs.ReaderBackground import io.github.landwarderer.futon.core.prefs.ReaderControl @@ -66,8 +72,44 @@ class ReaderSettingsFragment : } findPreference(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider() updateReaderModeDependency() + findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)?.summaryProvider = + Preference.SummaryProvider { + getString(R.string.smart_resume_percentage_summary, settings.smartResumePercentage) + } + findPreference(AppSettings.KEY_SMART_RESUME_PAGES)?.summaryProvider = + Preference.SummaryProvider { + val count = settings.smartResumePagesRemaining + resources.getQuantityString(R.plurals.smart_resume_pages_summary, count, count) + } + findPreference(AppSettings.KEY_SMART_RESUME_WAIT)?.run { + helpDescription = getString(R.string.smart_resume_wait_help_title) + onHelpClick = { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.smart_resume_wait_help_title) + .setMessage(R.string.smart_resume_wait_help) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + updateSmartResumeRule() } + @Suppress("DEPRECATION") + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference.key != AppSettings.KEY_SMART_RESUME_PERCENTAGE && + preference.key != AppSettings.KEY_SMART_RESUME_PAGES + ) { + super.onDisplayPreferenceDialog(preference) + return + } + val tag = "androidx.preference.PreferenceFragment.DIALOG" + if (parentFragmentManager.findFragmentByTag(tag) != null) return + SmartResumeThresholdDialog().apply { + arguments = bundleOf("key" to preference.key) + setTargetFragment(this@ReaderSettingsFragment, 0) + }.show(parentFragmentManager, tag) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) @@ -92,6 +134,7 @@ class ReaderSettingsFragment : override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { AppSettings.KEY_READER_MODE -> updateReaderModeDependency() + AppSettings.KEY_SMART_RESUME_MODE -> updateSmartResumeRule() } } @@ -100,4 +143,10 @@ class ReaderSettingsFragment : isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON } } + + private fun updateSmartResumeRule() { + val percentage = settings.smartResumeCompletionMode == ChapterCompletionMode.PERCENTAGE + findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)?.isVisible = percentage + findPreference(AppSettings.KEY_SMART_RESUME_PAGES)?.isVisible = !percentage + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/reader/SmartResumeThresholdDialog.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/reader/SmartResumeThresholdDialog.kt new file mode 100644 index 0000000000..8500cd4c5f --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/reader/SmartResumeThresholdDialog.kt @@ -0,0 +1,35 @@ +package io.github.landwarderer.futon.settings.reader + +import android.text.InputType +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.preference.EditTextPreference +import androidx.preference.EditTextPreferenceDialogFragmentCompat +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.prefs.AppSettings + +class SmartResumeThresholdDialog : EditTextPreferenceDialogFragmentCompat() { + override fun onStart() { + super.onStart() + val alert = dialog as? AlertDialog ?: return + val editor = alert.findViewById(android.R.id.edit) ?: return + val pref = preference as EditTextPreference + editor.inputType = InputType.TYPE_CLASS_NUMBER + alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val number = editor.text.toString().toIntOrNull() + val percentage = pref.key == AppSettings.KEY_SMART_RESUME_PERCENTAGE + val valid = number != null && if (percentage) number in 1..100 else number >= 0 + if (!valid) { + editor.error = getString( + if (percentage) R.string.smart_resume_percentage_error else R.string.smart_resume_pages_error, + ) + } else if (pref.callChangeListener(number.toString())) { + pref.text = number.toString() + dismiss() + } + } + } + + // The positive button validates and persists explicitly; cancel/back leave the value untouched. + override fun onDialogClosed(positiveResult: Boolean) = Unit +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/search/SettingsSearchHelper.kt index 752c102e94..0219c2ab14 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/search/SettingsSearchHelper.kt @@ -11,6 +11,7 @@ import dagger.Reusable import io.github.landwarderer.futon.R import io.github.landwarderer.futon.backups.ui.periodical.PeriodicalBackupSettingsFragment import io.github.landwarderer.futon.core.LocalizedAppContext +import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.settings.AppearanceSettingsFragment import io.github.landwarderer.futon.settings.DownloadsSettingsFragment import io.github.landwarderer.futon.settings.ProxySettingsFragment @@ -117,9 +118,15 @@ class SettingsSearchHelper @Inject constructor( fragmentClass = fragmentClass, ) } else { + // Both modes share one threshold row. Search should land on the always-visible parent. + if (pref.key == AppSettings.KEY_SMART_RESUME_PAGES) return@repeat result.add( SettingsItem( - key = pref.key ?: return@repeat, + key = if (pref.key == AppSettings.KEY_SMART_RESUME_PERCENTAGE) { + AppSettings.KEY_SMART_RESUME + } else { + pref.key ?: return@repeat + }, title = pref.title ?: return@repeat, breadcrumbs = breadcrumbs, fragmentClass = fragmentClass, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/HelpSwitchPreference.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/HelpSwitchPreference.kt new file mode 100644 index 0000000000..a6971eef42 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/utils/HelpSwitchPreference.kt @@ -0,0 +1,31 @@ +package io.github.landwarderer.futon.settings.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.preference.PreferenceViewHolder +import androidx.preference.SwitchPreferenceCompat +import io.github.landwarderer.futon.R + +class HelpSwitchPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : SwitchPreferenceCompat(context, attrs) { + var helpDescription: CharSequence? = null + var onHelpClick: (() -> Unit)? = null + + init { + widgetLayoutResource = R.layout.preference_help_switch_widget + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.findViewById(R.id.preference_help)?.run { + contentDescription = helpDescription + setOnClickListener { onHelpClick?.invoke() } + // Help remains available even when the feature's dependent switch is disabled. + isEnabled = true + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + } +} diff --git a/app/src/main/res/drawable/ic_help_outline.xml b/app/src/main/res/drawable/ic_help_outline.xml new file mode 100644 index 0000000000..e3b51dd7e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/layout/preference_help_switch_widget.xml b/app/src/main/res/layout/preference_help_switch_widget.xml new file mode 100644 index 0000000000..ffc922937d --- /dev/null +++ b/app/src/main/res/layout/preference_help_switch_widget.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/values/smart_resume.xml b/app/src/main/res/values/smart_resume.xml new file mode 100644 index 0000000000..467d2c48f6 --- /dev/null +++ b/app/src/main/res/values/smart_resume.xml @@ -0,0 +1,30 @@ + + + Smart resume + When new chapters arrive, continue with the next chapter if you nearly finished what was previously the latest chapter. + Consider a chapter finished based on + Percentage + Pages remaining + Completion threshold + At least %1$d%% of pages reached, including the current page. + + %d page or fewer remaining. + %d pages or fewer remaining. + + Enter a whole number from 1 to 100. + Enter a whole number from 0 to 2147483647. + Wait for chapter updates + Wait for Futon to check for chapter updates before choosing where to continue. + About waiting for chapter updates + Off: Resume using the chapter list Futon already has. If more chapters appear afterward, your reading position won’t change.\n\nOn: Wait for Futon’s usual chapter update before resuming. This may take longer. Futon may reuse previously downloaded chapter information instead of fetching a fresh list. If the update fails, resume at your saved position. + Count pages reached, including the page you’re on. At 90%, page 14 of 15 qualifies; page 13 does not. This does not measure how far you’ve scrolled within a long image. + Choose how many pages may remain after the page you’re on. Set this to 0 to require reaching the final page. This does not measure how far you’ve scrolled within a long image. + + @string/smart_resume_percentage + @string/smart_resume_pages_remaining + + + PERCENTAGE + PAGES_REMAINING + + diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index 1038569e45..c6df24b539 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -16,6 +16,45 @@ android:summary="@string/detect_reader_mode_summary" android:title="@string/detect_reader_mode" /> + + + + + + + + + + { + val repository = mock(MangaRepository::class.java) + val factory = mock(MangaRepository.Factory::class.java) + `when`(factory.create(TestMangaSource)).thenReturn(repository) + val chapters = (1L..2L).map { id -> + MangaChapter( + id = id, title = "Chapter $id", number = id.toFloat(), volume = 0, + url = "https://example.org/$id", uploadDate = 0L, + scanlator = null, branch = null, source = TestMangaSource, + ) + } + `when`(repository.getPages(chapters[0])).thenReturn(listOf(page(1))) + val details = mock(MangaDetails::class.java) + `when`(details.allChapters).thenReturn(chapters) + val loader = ChaptersLoader(factory) + loader.init(details) + loader.loadSingleChapter(1) + return Triple(loader, repository, chapters[1]) + } + + private fun page(id: Long) = MangaPage(id, "https://example.org/$id", null, TestMangaSource) +} diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolverTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolverTest.kt new file mode 100644 index 0000000000..f4a4d8f4e2 --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/reader/domain/SmartResumeResolverTest.kt @@ -0,0 +1,144 @@ +package io.github.landwarderer.futon.reader.domain + +import io.github.landwarderer.futon.core.model.MangaHistory +import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode +import io.github.landwarderer.futon.reader.ui.ReaderState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class SmartResumeResolverTest { + private val percentage = ChapterCompletionRule(ChapterCompletionMode.PERCENTAGE, 90, 1) + private val pages = ChapterCompletionRule(ChapterCompletionMode.PAGES_REMAINING, 90, 1) + + @Test + fun `percentage boundary counts the current page`() { + assertTrue(percentage.isCompleted(13, 15)) + assertTrue(percentage.isCompleted(14, 15)) + assertFalse(percentage.isCompleted(12, 15)) + assertTrue(percentage.isCompleted(8, 10)) + assertFalse(percentage.isCompleted(7, 10)) + assertFalse(percentage.copy(percentage = 95).isCompleted(13, 15)) + assertTrue(percentage.copy(percentage = 100).isCompleted(14, 15)) + } + + @Test + fun `pages remaining allows the final and second to last page`() { + assertTrue(pages.isCompleted(14, 15)) + assertTrue(pages.isCompleted(13, 15)) + assertFalse(pages.isCompleted(12, 15)) + assertFalse(pages.copy(pagesRemaining = 0).isCompleted(13, 15)) + assertTrue(pages.copy(pagesRemaining = 0).isCompleted(14, 15)) + } + + @Test + fun `short chapters invalid positions and large counts`() { + for (rule in listOf(percentage, pages)) { + assertTrue(rule.isCompleted(0, 1)) + assertFalse(rule.isCompleted(-1, 15)) + assertFalse(rule.isCompleted(15, 15)) + assertFalse(rule.isCompleted(0, 0)) + assertTrue(rule.isCompleted(Int.MAX_VALUE - 1, Int.MAX_VALUE)) + } + assertFalse(percentage.copy(percentage = 101).isCompleted(14, 15)) + assertFalse(pages.copy(pagesRemaining = -1).isCompleted(14, 15)) + } + + @Test + fun `previously latest advances only one chapter and resets scroll`() = runTest { + val resolver = resolver() + val loaded = mutableListOf() + assertEquals(ReaderState(30, 0, 0), resolver.resolve(ids(32), 15, false) { loaded += it; true }) + assertEquals(listOf(30L), loaded) + } + + @Test + fun `old chapter existing successor and unfinished positions never advance`() = runTest { + for (history in listOf(history(count = 30), history(page = 12), history(count = 0))) { + val start = ReaderState(history) + val resolver = SmartResumeResolver(start, history, percentage, true) + assertEquals(start, resolver.resolve(ids(32), 15, false) { error("Must not load") }) + } + } + + @Test + fun `explicit restored recovered and new starts have no eligible history`() = runTest { + val start = ReaderState(29, 14, 120) + val resolver = SmartResumeResolver(start, null, percentage, true) + assertEquals(start, resolver.resolve(ids(30), 15, false) { error("Must not load") }) + } + + @Test + fun `missing chapter and different branch do not advance`() = runTest { + assertEquals(ReaderState(history()), resolver().resolve(listOf(100, 101), 15, true) { error("Wrong branch") }) + assertEquals(ReaderState(history()), resolver().resolve(ids(28), 15, true) { error("Missing chapter") }) + assertEquals(ReaderState(history()), resolver().resolve(ids(29), 15, true) { error("No new chapter") }) + } + + @Test + fun `cached decision cannot jump after an update`() = runTest { + val resolver = resolver() + assertEquals(ReaderState(history()), resolver.resolve(ids(29), 15, false) { error("No successor") }) + assertEquals(ReaderState(history()), resolver.resolve(ids(30), 15, true) { error("Already decided") }) + } + + @Test + fun `waiting holds the original history until updated details arrive`() = runTest { + val saved = history() + val resolver = SmartResumeResolver(ReaderState(saved), saved, percentage, true) + assertNull(resolver.resolve(ids(29), 15, false) { error("Not ready") }) + assertNull(resolver.resolve(ids(30), 15, false) { error("Still not ready") }) + assertEquals(ReaderState(30, 0, 0), resolver.resolve(ids(32), 15, true) { true }) + assertEquals(29, saved.chaptersCount) + assertEquals(ReaderState(30, 0, 0), resolver.resolve(ids(33), 15, true) { error("Second jump") }) + } + + @Test + fun `normal flow completion can release a deferred start`() = runTest { + val resolver = resolver(wait = true) + assertNull(resolver.resolve(ids(29), 15, false) { error("No successor") }) + assertEquals(ReaderState(history()), resolver.resolve(ids(29), 15, true) { error("No successor") }) + } + + @Test + fun `update failure permanently releases original state`() = runTest { + val resolver = resolver(wait = true) + assertNull(resolver.resolve(ids(30), 15, false) { error("Not ready") }) + assertEquals(ReaderState(history()), resolver.fallback()) + assertEquals(ReaderState(history()), resolver.resolve(ids(30), 15, true) { error("Already fell back") }) + } + + @Test + fun `empty and failing successors fall back to the exact position`() = runTest { + assertEquals(ReaderState(history()), resolver().resolve(ids(30), 15, true) { false }) + assertEquals(ReaderState(history()), resolver().resolve(ids(30), 15, true) { throw IllegalStateException() }) + } + + @Test(expected = CancellationException::class) + fun `cancellation propagates`() = runTest { + resolver().resolve(ids(30), 15, true) { throw CancellationException() } + Unit + } + + @Test + fun `unknown completion mode defaults to percentage`() { + assertEquals(ChapterCompletionMode.PERCENTAGE, ChapterCompletionMode.from(null)) + assertEquals(ChapterCompletionMode.PERCENTAGE, ChapterCompletionMode.from("invalid")) + assertEquals(ChapterCompletionMode.PAGES_REMAINING, ChapterCompletionMode.from("PAGES_REMAINING")) + } + + private fun resolver(wait: Boolean = false) = SmartResumeResolver( + ReaderState(history()), history(), percentage, wait, + ) + + private fun history(page: Int = 13, count: Int = 29) = MangaHistory( + Instant.EPOCH, Instant.EPOCH, 29, page, 120, 1f, count, + ) + + private fun ids(count: Int) = (1L..count.toLong()).toList() +}