From 22f26caf067bae4ec6a7cb6af88fa9f8c3cbaa96 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 01/27] fix(downloads): stop the queue dropping chapters and deleting other downloads' temp files * fix(downloads): only clean up a download's own temp files Concurrent downloads to the same storage dir deleted each other's temp files. Ported from Kotatsu-Redo ddc4be1f1. * fix(downloads): stop the queue silently dropping chapters Queued downloads are enqueued as unique work per manga with KEEP, and the scheduler only waited while a download was RUNNING. A second queue entry for a manga whose earlier download was still ENQUEUED or BLOCKED (e.g. waiting for charging or an unmetered network) was discarded by KEEP and then removed from the queue, losing its chapters. Leave such entries in the queue until the earlier download finishes, and only remove an entry after its work has been stored. --- .../ui/worker/DownloadSchedulerWorker.kt | 24 +++++++++++++++++-- .../download/ui/worker/DownloadWorker.kt | 15 ++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt index 4112919aef..1ae9a829b7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadSchedulerWorker.kt @@ -12,10 +12,12 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.await import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.util.ext.awaitUniqueWorkInfoByName import io.github.landwarderer.futon.core.util.ext.awaitWorkInfosByTag import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository @@ -54,7 +56,18 @@ class DownloadSchedulerWorker @AssistedInject constructor( return@runCatchingCancellable Result.success() } + // A manga whose previous queued download has not finished yet (e.g. still waiting for its + // network or charging constraints) must wait: its work is unique per manga and enqueued with + // KEEP, so a second request would be silently discarded together with its chapters. + val busyMangaIds = queue.mapTo(HashSet()) { it.mangaId }.filterTo(HashSet()) { mangaId -> + workManager.awaitUniqueWorkInfoByName(uniqueWorkName(mangaId)).any { !it.state.isFinished } + } + if (busyMangaIds.isNotEmpty()) { + Log.d("DownloadScheduler", "Waiting for unfinished downloads of manga $busyMangaIds") + } + val nextItem = queue.firstOrNull { + if (it.mangaId in busyMangaIds) return@firstOrNull false if (it.isPaused && !force) return@firstOrNull false if (force) return@firstOrNull true @@ -78,6 +91,10 @@ class DownloadSchedulerWorker @AssistedInject constructor( if (nextItem == null) { scheduleAlarmCheck() + if (queue.any { it.mangaId in busyMangaIds }) { + // after the alarm check, which may replace the pending check with a longer delay + scheduleNextCheck(30, TimeUnit.SECONDS) + } return@runCatchingCancellable Result.success() } @@ -113,11 +130,12 @@ class DownloadSchedulerWorker @AssistedInject constructor( Log.d("DownloadScheduler", "Enqueuing download for manga ${nextItem.mangaId}") workManager.enqueueUniqueWork( - "download_${nextItem.mangaId}", + uniqueWorkName(nextItem.mangaId), ExistingWorkPolicy.KEEP, downloadRequest - ) + ).await() + // Only once the work is safely stored, or the chapters would be lost downloadQueueRepository.removeFromQueue(nextItem.id) // Schedule next check to process the rest of the queue @@ -220,6 +238,8 @@ class DownloadSchedulerWorker @AssistedInject constructor( const val WORK_NAME_SCHEDULER = "download_scheduler_periodic" const val KEY_FORCE = "force" + private fun uniqueWorkName(mangaId: Long) = "download_$mangaId" + fun enqueue(workManager: WorkManager, delay: Long = 0, force: Boolean = false) { Log.d("DownloadScheduler", "Enqueuing scheduler with delay: $delay ms, force=$force") val request = OneTimeWorkRequestBuilder() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt index 8227b2ac33..4f07ae0edc 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt @@ -62,7 +62,6 @@ import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.LocalStorageCache import io.github.landwarderer.futon.local.data.LocalStorageChanges import io.github.landwarderer.futon.local.data.PageCache -import io.github.landwarderer.futon.local.data.TempFileFilter import io.github.landwarderer.futon.local.data.input.LocalMangaParser import io.github.landwarderer.futon.local.data.output.LocalMangaOutput import io.github.landwarderer.futon.local.domain.DeleteReadChaptersUseCase @@ -98,6 +97,7 @@ import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.UUID +import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -210,6 +210,7 @@ class DownloadWorker @AssistedInject constructor( ) val destination = localMangaRepository.getOutputDir(manga, task.destination) checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } + val tempFiles = ConcurrentLinkedQueue() var output: LocalMangaOutput? = null try { if (manga.isLocal) { @@ -225,7 +226,7 @@ class DownloadWorker @AssistedInject constructor( ) val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (!coverUrl.isNullOrEmpty()) { - downloadFile(coverUrl, destination, repo.source).let { file -> + downloadFile(coverUrl, destination, repo.source, tempFiles).let { file -> output.addCover(file, getMediaType(coverUrl, file)) file.deleteAwait() } @@ -250,7 +251,7 @@ class DownloadWorker @AssistedInject constructor( runFailsafe { val url = repo.getPageUrl(page) val file = cache[url] - ?: downloadFile(url, destination, repo.source) + ?: downloadFile(url, destination, repo.source, tempFiles) output.addPage( chapter = chapter, file = file, @@ -330,9 +331,7 @@ class DownloadWorker @AssistedInject constructor( applicationContext.unregisterReceiver(pausingReceiver) output?.closeQuietly() output?.cleanup() - destination.listFiles(TempFileFilter())?.forEach { - it.deleteAwait() - } + tempFiles.forEach { it.deleteAwait() } } } } @@ -407,6 +406,7 @@ class DownloadWorker @AssistedInject constructor( url: String, destination: File, source: MangaSource, + tempFiles: MutableCollection, ): File { if (url.startsWith("content:", ignoreCase = true) || url.startsWith("file:", ignoreCase = true)) { val uri = url.toUri() @@ -415,6 +415,7 @@ class DownloadWorker @AssistedInject constructor( MimeTypes.getNormalizedExtension(it.name) } ?: cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) } val file = destination.createTempFile(ext) + tempFiles += file try { cr.openSource(uri).use { input -> file.sink(append = false).buffer().use { @@ -437,7 +438,7 @@ class DownloadWorker @AssistedInject constructor( response.requireBody().use { body -> file = destination.createTempFile( ext = MimeTypes.getExtension(body.contentType()?.toMimeType()) - ) + ).also { tempFiles += it } file.sink(append = false).buffer().use { it.writeAllCancellable(body.source(), bandwidthLimiter) } From 96aacf3a3e73acd4aff8aefcfa48af1966e7cf51 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 02/27] fix(backup): restore the selected sources instead of adding to them A backup only holds the enabled sources, and restoring them was a plain upsert, so every source already enabled stayed on. On a fresh install that is the welcome sheet's language defaults (or the 15 fallback defaults), so the restored selection was buried among them. Unless merging, disable all sources before restoring the backed-up ones. --- .../settings/backup/AppBackupAgentTest.kt | 23 +++++++++++++++++++ .../futon/backups/data/BackupRepository.kt | 13 +++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/backup/AppBackupAgentTest.kt index 797728abd9..31369ea141 100644 --- a/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/backup/AppBackupAgentTest.kt +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/settings/backup/AppBackupAgentTest.kt @@ -90,6 +90,29 @@ class AppBackupAgentTest { assertTrue(SampleData.tag in allTags) } + @Test + fun restoreReplacesEnabledSources() = runTest { + val dao = database.getSourcesDao() + dao.setEnabled("MANGADEX", true) + dao.setEnabled("MANGAPARK", true) + + val agent = AppBackupAgent() + val backup = agent.createBackupFile( + context = InstrumentationRegistry.getInstrumentation().targetContext, + repository = backupRepository, + ) + + // as after a fresh install, where other sources are enabled by default + dao.setEnabled("MANGADEX", false) + dao.setEnabled("NHENTAI", true) + + backup.inputStream().use { + agent.restoreBackupFile(it.fd, backup.length(), backupRepository) + } + + assertEquals(setOf("MANGADEX", "MANGAPARK"), dao.findAllEnabledNames().toSet()) + } + @Test fun restoreOldBackup() { val agent = AppBackupAgent() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt index 62b3c43555..a331ac3de4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/backups/data/BackupRepository.kt @@ -191,8 +191,17 @@ class BackupRepository @Inject constructor( getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() }) } - BackupSection.SOURCES -> input.readJsonArray(serializer()).restoreToDb { - getSourcesDao().upsert(it.toEntity()) + BackupSection.SOURCES -> { + val sources = input.readJsonArray(serializer()).toList() + if (!isMerge && sources.isNotEmpty()) { + // The backup holds only the enabled sources, so a plain upsert would keep every + // source already enabled here, e.g. the defaults enabled on a fresh install. + // Replace the selection instead; merging keeps adding to it. + database.getSourcesDao().disableAllSources() + } + sources.asSequence().restoreToDb { + getSourcesDao().upsert(it.toEntity()) + } } BackupSection.SCROBBLING -> input.readJsonArray(serializer()).restoreToDb { From 4835d2a7ba62e77c851b0e2058a239cc5cf06d0a Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:02:12 +0000 Subject: [PATCH 03/27] fix(settings): restore float preferences from backups Ported from Kotatsu-Redo 9.8.1. --- .../futon/core/prefs/AppSettings.kt | 26 ++++++++++++++++--- .../futon/core/util/ext/Preferences.kt | 1 + 2 files changed, 23 insertions(+), 4 deletions(-) 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 32273027e5..a35af1e84c 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 @@ -200,7 +200,7 @@ class AppSettings internal constructor( @get:FloatRange(0.0, 1.0) var readerDoublePagesSensitivity: Float - get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f) + get() = getFloatCompat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f) set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) } val readerScreenOrientation: Int @@ -506,8 +506,8 @@ class AppSettings internal constructor( var readerColorFilter: ReaderColorFilter? get() = runCatching { ReaderColorFilter( - brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness), - contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast), + brightness = getFloatCompat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness), + contrast = getFloatCompat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast), isInverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted), isGrayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale), isBookBackground = prefs.getBoolean(KEY_CF_BOOK, ReaderColorFilter.EMPTY.isBookBackground), @@ -594,7 +594,7 @@ class AppSettings internal constructor( @get:FloatRange(from = 0.0, to = 1.0) var readerAutoscrollSpeed: Float - get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) + get() = getFloatCompat(KEY_READER_AUTOSCROLL_SPEED, 0f) set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat( KEY_READER_AUTOSCROLL_SPEED, @@ -797,6 +797,24 @@ class AppSettings internal constructor( } } + // Float preferences restored from a JSON backup may come back as another type + private fun getFloatCompat(key: String, defaultValue: Float): Float = try { + prefs.getFloat(key, defaultValue) + } catch (_: ClassCastException) { + val fixedValue = when (val rawValue = prefs.all[key]) { + is Number -> rawValue.toFloat() + is String -> rawValue.toFloatOrNull() + else -> null + } + if (fixedValue == null || !fixedValue.isFinite()) { + prefs.edit { remove(key) } + defaultValue + } else { + prefs.edit { putFloat(key, fixedValue) } + fixedValue + } + } + companion object { @Volatile diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Preferences.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Preferences.kt index 2fc4164a64..a59ccb55f4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Preferences.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Preferences.kt @@ -63,6 +63,7 @@ fun SharedPreferences.Editor.putAll(values: Map) { is Int -> putInt(e.key, v) is Long -> putLong(e.key, v) is Float -> putFloat(e.key, v) + is Double -> putFloat(e.key, v.toFloat()) is String -> putString(e.key, v) is JSONArray -> putStringSet(e.key, v.toStringSet()) } From 9a295dbc82ccebde10c0b5436af822517d7a1523 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:02:12 +0000 Subject: [PATCH 04/27] fix(local): scan storage outside the index rebuild transaction Holding the write connection during the scan stalled Room on cold start. Ported from Kotatsu-Redo 9.8.5. --- .../futon/local/data/index/LocalMangaIndex.kt | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt index 6605c95a0f..05976116c0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/local/data/index/LocalMangaIndex.kt @@ -11,6 +11,7 @@ import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.data.input.LocalMangaParser import io.github.landwarderer.futon.local.domain.model.LocalManga import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @@ -41,40 +42,25 @@ class LocalMangaIndex @Inject constructor( } suspend fun update() = mutex.withLock { - println("LocalMangaIndex: Starting update") - runCatchingCancellable { - db.withTransaction { - val dao = db.getLocalMangaIndexDao() - dao.clear() - localMangaRepositoryProvider.get() - .getRawListAsFlow() - .collect { - println("LocalMangaIndex: Found manga ${it.manga.title} at ${it.file.path}") - upsert(it) - } - } - currentVersion = VERSION - println("LocalMangaIndex: Update completed") - }.onFailure { - it.printStackTraceDebug("LocalMangaIndex::update") - } + rebuild() } suspend fun updateIfRequired() { if (isUpdateRequired()) { - update() + mutex.withLock { + // The first build may have finished while this caller was waiting for it + if (isUpdateRequired()) { + rebuild() + } + } } } suspend fun get(mangaId: Long, withDetails: Boolean): LocalManga? { updateIfRequired() - var path = db.getLocalMangaIndexDao().findPath(mangaId) - if (path == null && mutex.isLocked) { // wait for updating complete - path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) } - } - if (path == null) { - return null - } + // A rebuild in progress leaves the previous index in place until it commits, so a miss here + // is an answer rather than a reason to wait for the whole storage scan + val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null return runCatchingCancellable { LocalMangaParser(File(path)).getManga(withDetails) }.onFailure { @@ -105,6 +91,36 @@ class LocalMangaIndex @Inject constructor( } } + /** + * Scans local storage first and writes the index afterwards, in one short transaction. + * + * The scan opens every archive and folder in every storage directory: minutes on a large library + * or slow storage, and forever if a read hangs. It used to run inside the transaction, holding + * SQLite's only write connection the whole time. Every other transaction queued behind it, and so + * did every newly subscribed Room Flow, since subscribing installs triggers through the writer. + * Those waits block threads of Room's 4-thread executor, and once it is exhausted nothing can be + * read at all - history, favourites and sources stay empty. This runs on every cold start. + */ + private suspend fun rebuild() { + println("LocalMangaIndex: Starting update") + runCatchingCancellable { + val found = localMangaRepositoryProvider.get() + .getRawListAsFlow() + .toList() + db.withTransaction { + db.getLocalMangaIndexDao().clear() + found.forEach { + println("LocalMangaIndex: Found manga ${it.manga.title} at ${it.file.path}") + upsert(it) + } + } + currentVersion = VERSION + println("LocalMangaIndex: Update completed") + }.onFailure { + it.printStackTraceDebug("LocalMangaIndex::update") + } + } + private suspend fun upsert(manga: LocalManga) { mangaDataRepository.storeManga(manga.manga, replaceExisting = true) db.getLocalMangaIndexDao().upsert(manga.toEntity()) From acb4e67569bdb5dd37f97e0121cd72c29d5d27df Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:01:35 +0000 Subject: [PATCH 05/27] fix(suggestions): keep the most relevant suggestions, not the least The worker sorted ascending and kept the least relevant suggestions. The notification picker also crashed with fewer than 3 suggestions (Random.nextInt(0, 0)). Ported from Kotatsu-Redo 9.8.5. --- .../landwarderer/futon/suggestions/ui/SuggestionsWorker.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt index 50bfdd8096..3e93e41fc6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt @@ -211,15 +211,16 @@ class SuggestionsWorker @AssistedInject constructor( relevance = computeRelevance(manga.tags, tags), ) }.toList() - .sortedBy { it.relevance } + .sortedByDescending { it.relevance } .take(MAX_RESULTS) suggestionRepository.replace(suggestions) - if (appSettings.isSuggestionsNotificationAvailable + if (suggestions.isNotEmpty() + && appSettings.isSuggestionsNotificationAvailable && applicationContext.checkNotificationPermission(MANGA_CHANNEL_ID) ) { for (i in 0..3) { try { - val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] + val manga = suggestions[Random.nextInt(0, (suggestions.size / 3).coerceAtLeast(1))] val details = mangaRepositoryFactory.create(manga.manga.source) .getDetails(manga.manga) if (details.chapters.isNullOrEmpty()) { From 21f5cc9d287e29f84bea61682934e5da8b9393c3 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:01:35 +0000 Subject: [PATCH 06/27] fix(details): hide NSFW related manga when NSFW content is disabled Ported from Kotatsu-Redo 9.7.7. --- .../landwarderer/futon/details/ui/DetailsViewModel.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt index 161ca9482b..c193f07255 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.R import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository import io.github.landwarderer.futon.core.model.getPreferredBranch +import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.nav.MangaIntent import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ListMode @@ -154,8 +155,15 @@ class DetailsViewModel @Inject constructor( val relatedManga: StateFlow> = manga.mapLatest { if (it != null && settings.isRelatedMangaEnabled) { + val related = relatedMangaUseCase(it).orEmpty().let { list -> + if (settings.isNsfwContentDisabled) { + list.filterNot { manga -> manga.isNsfw() } + } else { + list + } + } mangaListMapper.toListModelList( - manga = relatedMangaUseCase(it).orEmpty(), + manga = related, mode = ListMode.GRID, ) } else { From f2454ddadcb707444b1be9eeb8323addbc030d37 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:28 +0000 Subject: [PATCH 07/27] feat(suggestions): rank by tag rarity and fall back to tags for related manga * feat(suggestions): rank local suggestion tags by rarity - Skip dropped manga (barely read, untouched for 3 weeks, not a favourite) when building the seed. - Pick the reader's most telling tags by TF-IDF against tag frequency across the local database (TagStatistics, TagsDao.countMangaPerTag) instead of the most frequent ones; blacklisted tags are not seeds. - Never search a source with no tag (its whole catalogue); search two matched tags together when supported, falling back to one. - Prefer rating over popularity sort order. - Suggestions are now enabled by default. Ported from Kotatsu-Redo 9.8.5 (9fb36ac74), local parts only. * fix(suggestions): keep suggestions off by default Redo turned them on by default, which would start a background worker hitting every enabled source for existing users who never opted in. * feat(details): fall back to tag matching for related manga When the source links fewer than four related titles, fill the row from the reader's stored suggestions that share this manga's tags (weighted by tag rarity), then from one search of the manga's source for its rarest tags matched against the source's filter tags. NSFW and the tag blacklist are respected; the source tag search is skipped for extension sources, whose shared repository has stateful paging. The related list screen uses the same use case. Ported from Kotatsu-Redo 9.8.5 (9fb36ac74), without the community server. --- .../landwarderer/futon/core/db/dao/TagsDao.kt | 20 +++ .../details/domain/RelatedMangaUseCase.kt | 146 +++++++++++++++++- .../ui/related/RelatedListViewModel.kt | 8 +- .../suggestions/domain/SuggestionRanking.kt | 66 ++++++++ .../domain/SuggestionRepository.kt | 7 + .../futon/suggestions/domain/TagStatistics.kt | 53 +++++++ .../futon/suggestions/ui/SuggestionsWorker.kt | 57 +++++-- .../domain/SuggestionRankingTest.kt | 61 ++++++++ 8 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRanking.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagStatistics.kt create mode 100644 app/src/test/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRankingTest.kt diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt index ea2f203f95..f6c6f7d0f0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TagsDao.kt @@ -86,6 +86,26 @@ abstract class TagsDao { ) abstract suspend fun findRelatedTags(ids: Set): List + /** + * How many stored manga carry each tag, by title across every source: how common a tag is in + * everything the app has seen, which is what tells `action` from `villainess`. + */ + @Query( + """SELECT LOWER(TRIM(tags.title)) AS title, COUNT(DISTINCT manga_tags.manga_id) AS count FROM tags + JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + GROUP BY LOWER(TRIM(tags.title))""", + ) + abstract suspend fun countMangaPerTag(): List + + @Query("SELECT COUNT(DISTINCT manga_id) FROM manga_tags") + abstract suspend fun countTaggedManga(): Int + @Upsert abstract suspend fun upsert(tags: Iterable) } + +/** One row of [TagsDao.countMangaPerTag]: a lowercased tag title and how many manga carry it. */ +data class TagFrequency( + val title: String, + val count: Int, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/RelatedMangaUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/RelatedMangaUseCase.kt index 46c33d3d8f..868adcb61a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/RelatedMangaUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/RelatedMangaUseCase.kt @@ -1,18 +1,156 @@ package io.github.landwarderer.futon.details.domain +import io.github.landwarderer.futon.core.model.isNsfw +import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.parser.MangaRepository +import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.mihon.MihonMangaRepository +import io.github.landwarderer.futon.suggestions.domain.SuggestionRanking +import io.github.landwarderer.futon.suggestions.domain.SuggestionRepository +import io.github.landwarderer.futon.suggestions.domain.TagStatistics +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject +/** + * What to read next, given this manga - for every manga, not only the ones the source links. + * + * The source's own related list comes first. Plenty of parsers link nothing, so when it comes up + * short the row is filled from this manga's tags: first the reader's own stored suggestions that + * share them, which costs nothing, then one search of this manga's source for its least common tags. + */ class RelatedMangaUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, + private val suggestionRepository: SuggestionRepository, + private val dataRepository: MangaDataRepository, + private val tagStatistics: TagStatistics, + private val settings: AppSettings, ) { - suspend operator fun invoke(seed: Manga) = runCatchingCancellable { + suspend operator fun invoke(seed: Manga): List? = coroutineScope { + val excludeNsfw = settings.isNsfwContentDisabled + val blacklist = settings.tagsBlacklist.mapTo(HashSet(), SuggestionRanking::normalizeTag) + val sourceJob = async { sourceRelated(seed) } + val stored = runCatchingCancellable { suggestionRepository.getTopList(MAX_STORED) } + .onFailure { it.printStackTraceDebug("RelatedMangaUseCase::stored") } + .getOrDefault(emptyList()) + + val picked = LinkedHashMap() + fun add(list: List) = list.forEach { + if (it.id != seed.id && !(excludeNsfw && it.isNsfw()) && !it.isBlacklisted(blacklist)) { + picked.putIfAbsent(it.id, it) + } + } + add(sourceJob.await().orEmpty()) + if (picked.size >= MIN_RESULTS) { + return@coroutineScope picked.values.take(LIMIT) + } + // The details may not have loaded - a source behind a challenge, offline - and then the manga + // carries no tags; the ones stored when it last loaded are just as good for this. + val tagged = if (seed.tags.isEmpty()) { + runCatchingCancellable { dataRepository.findMangaById(seed.id, withChapters = false) } + .getOrNull()?.takeIf { it.tags.isNotEmpty() } ?: seed + } else { + seed + } + // Blacklisted tags say nothing the reader wants more of, so they are never matched on. + val seedTags = tagged.tags.filterNot { SuggestionRanking.normalizeTag(it.title) in blacklist } + if (seedTags.isEmpty()) { + return@coroutineScope picked.values.take(LIMIT).ifEmpty { null } + } + val rarity = runCatchingCancellable { tagStatistics.snapshot() }.getOrNull() + ?.let { snapshot -> { title: String -> snapshot.rarity(title) } } + ?: { _: String -> 1.0 } + add(likeByTags(seedTags, stored, rarity)) + if (picked.size < MIN_RESULTS) { + add(sourceByTags(tagged, seedTags, rarity)) + } + picked.values.take(LIMIT).ifEmpty { null } + } + + private suspend fun sourceRelated(seed: Manga): List? = runCatchingCancellable { mangaRepositoryFactory.create(seed.source).getRelated(seed) - }.onFailure { - it.printStackTraceDebug("RelatedMangaUseCase::invoke") - }.getOrNull() + }.onFailure { it.printStackTraceDebug("RelatedMangaUseCase::sourceRelated") }.getOrNull() + + /** + * The reader's stored suggestions that share this manga's tags, closest first - already + * resolved and on the device, so no request at all. Closeness weighs each shared tag by how + * rare it is, so sharing `villainess` beats sharing `action`. + */ + private fun likeByTags(seedTags: Collection, stored: List, rarity: (String) -> Double): List { + val seedTitles = seedTags.titles() + // One shared tag is a coincidence unless the manga has hardly any + val minShared = if (seedTitles.size <= 2) 1 else 2 + return stored.mapNotNull { manga -> + val tags = manga.tags.titles() + if (tags.count { it in seedTitles } < minShared) { + null + } else { + manga to SuggestionRanking.weightedOverlap(seedTitles, tags, rarity) + } + }.sortedByDescending { it.second } + .map { it.first } + .take(LIMIT) + } + + /** + * One search of this manga's own source for its rarest tags. + * + * Tags are matched against the source's own filter tags by title, so this also works for sources + * whose details tags are not usable as filters (extension sources), and never searches with no + * tag at all - that would be the source's whole catalogue. Never sorted by popularity first. + */ + private suspend fun sourceByTags( + seed: Manga, + seedTags: Collection, + rarity: (String) -> Double, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(seed.source) + if (repository is MihonMangaRepository) { + // Its list paging is stateful and the instance is shared, so a background search here + // would make the source list the reader came from skip or repeat a page. + return@runCatchingCancellable emptyList() + } + val available = repository.getFilterOptions().availableTags + .associateBy { SuggestionRanking.normalizeTag(it.title) } + val byRarity = seedTags.sortedByDescending { rarity(it.title) } + .mapNotNull { available[SuggestionRanking.normalizeTag(it.title)] } + .distinct() + val first = byRarity.firstOrNull() ?: return@runCatchingCancellable emptyList() + val order = PREFERRED_ORDERS.firstOrNull { it in repository.sortOrders } + val seedTitles = seedTags.titles() + val tagCount = if (repository.filterCapabilities.isMultipleTagsSupported) 2 else 1 + var found = repository.getList(0, order, MangaListFilter(tags = byRarity.take(tagCount).toSet())) + if (found.none { it.id != seed.id } && byRarity.size > 1 && tagCount > 1) { + // Two rare tags together can match nothing but the manga itself + found = repository.getList(0, order, MangaListFilter(tags = setOf(first))) + } + found.filter { it.id != seed.id } + .sortedByDescending { SuggestionRanking.weightedOverlap(seedTitles, it.tags.titles(), rarity) } + .take(LIMIT) + }.onFailure { it.printStackTraceDebug("RelatedMangaUseCase::sourceByTags") }.getOrDefault(emptyList()) + + private fun Collection.titles(): Set = mapTo(HashSet()) { SuggestionRanking.normalizeTag(it.title) } + + private fun Manga.isBlacklisted(blacklist: Set): Boolean = + blacklist.isNotEmpty() && tags.any { SuggestionRanking.normalizeTag(it.title) in blacklist } + + private companion object { + const val LIMIT = 20 + + /** Fewer than this and the row looks empty, so the next source of answers is asked too. */ + const val MIN_RESULTS = 4 + + /** The reader's suggestions to match tags against; they are stored best first. */ + const val MAX_STORED = 200 + + /** Anything but popularity, which would hand back the same best-sellers for every manga. */ + val PREFERRED_ORDERS = listOf(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.RATING, SortOrder.POPULARITY) + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt index 5ce22c9f47..1e72cc156f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt @@ -7,11 +7,11 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaDataRepository -import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.require +import io.github.landwarderer.futon.details.domain.RelatedMangaUseCase import io.github.landwarderer.futon.list.domain.MangaListMapper import io.github.landwarderer.futon.list.ui.MangaListViewModel import io.github.landwarderer.futon.list.ui.model.EmptyState @@ -34,15 +34,14 @@ import javax.inject.Inject @HiltViewModel class RelatedListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, settings: AppSettings, + private val relatedMangaUseCase: RelatedMangaUseCase, private val mangaListMapper: MangaListMapper, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges) { private val seed = savedStateHandle.require(AppRouter.KEY_MANGA).manga - private val repository = mangaRepositoryFactory.create(seed.source) private val mangaList = MutableStateFlow?>(null) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null @@ -79,7 +78,8 @@ class RelatedListViewModel @Inject constructor( return launchLoadingJob(Dispatchers.IO) { try { listError.value = null - mangaList.value = repository.getRelated(seed) + // Same path as the details screen row: the source's own related list, then tag matches. + mangaList.value = relatedMangaUseCase(seed).orEmpty() } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRanking.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRanking.kt new file mode 100644 index 0000000000..51e2f962fc --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRanking.kt @@ -0,0 +1,66 @@ +package io.github.landwarderer.futon.suggestions.domain + +import java.time.Duration +import java.time.Instant + +/** + * The local ranking decisions, kept free of Android code so each one can be tested on its own. + */ +object SuggestionRanking { + + /** Under a tenth read... */ + const val DROPPED_MAX_PERCENT = 0.1f + + /** ...and untouched this long counts as left, not merely paused. */ + val DROPPED_AFTER: Duration = Duration.ofDays(21) + + /** + * Opened, barely read and left for weeks. [percent] is the reading progress from history, which + * is negative when unknown - and unknown is not evidence of anything. + */ + fun isDropped(percent: Float, updatedAt: Instant, now: Instant): Boolean = + percent in 0f..DROPPED_MAX_PERCENT && Duration.between(updatedAt, now) > DROPPED_AFTER + + /** Tag titles compared across sources: `Action`, ` action ` and `ACTION` are one tag. */ + fun normalizeTag(title: String): String = title.trim().lowercase() + + /** + * The reader's most telling tags, most telling first: frequent in what they read and rare in + * everything else - TF-IDF, with [rarity] as the inverse document frequency. + * + * Ranking by frequency alone always crowns `action` or `fantasy`, and searching a source for those + * returns its most popular titles rather than anything close to this reader. A tag on only one of + * the reader's manga comes after every recurring one however rare it is: one title's odd tag is + * not a taste. + * + * @param seedTags the tag titles of every manga read, one entry per manga carrying it + * @return original titles, as the sources spell them + */ + fun distinctiveTags(seedTags: List, rarity: (String) -> Double, limit: Int): List { + val counts = LinkedHashMap>() + seedTags.forEach { title -> + val key = normalizeTag(title) + if (key.isEmpty()) return@forEach + val existing = counts[key] + counts[key] = (existing?.first ?: title) to ((existing?.second ?: 0) + 1) + } + val (recurring, once) = counts.values.partition { it.second >= 2 } + fun rank(tags: List>) = tags + .sortedByDescending { (title, count) -> count * rarity(title) } + .map { it.first } + return (rank(recurring) + rank(once)).take(limit) + } + + /** + * Overlap of two tag sets with each tag weighted by its [rarity]: sharing `villainess` counts for + * far more than sharing `action`, and a manga carrying thirty tags does not win by sheer number. + */ + fun weightedOverlap(a: Set, b: Set, rarity: (String) -> Double): Double { + if (a.isEmpty() || b.isEmpty()) { + return 0.0 + } + val shared = a.sumOf { if (it in b) rarity(it) else 0.0 } + val union = a.sumOf(rarity) + b.sumOf { if (it in a) 0.0 else rarity(it) } + return if (union > 0.0) shared / union else 0.0 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRepository.kt index f0dd947e20..0cf0296c57 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRepository.kt @@ -33,6 +33,13 @@ class SuggestionRepository @Inject constructor( } } + /** The stored suggestions, best first, with their tags. */ + suspend fun getTopList(limit: Int): List { + return db.getSuggestionDao().getTopManga(limit).map { + it.toManga() + } + } + suspend fun getRandomList(limit: Int): List { return db.getSuggestionDao().getRandom(limit).map { it.toManga() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagStatistics.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagStatistics.kt new file mode 100644 index 0000000000..b3abfcc6ca --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/domain/TagStatistics.kt @@ -0,0 +1,53 @@ +package io.github.landwarderer.futon.suggestions.domain + +import io.github.landwarderer.futon.core.db.MangaDatabase +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.ln + +/** + * How common each tag is across everything the app has stored - every list browsed, every + * suggestion, every manga opened, from every source. + * + * That is the background a reader's taste is measured against: reading a lot of `action` says + * little when nearly everything is tagged `action`, while reading a lot of `villainess` says a great + * deal. Counted on the device, from what is already in its database. + */ +@Singleton +class TagStatistics @Inject constructor( + private val db: MangaDatabase, +) { + + private val mutex = Mutex() + private var cached: Snapshot? = null + + /** Cached for a few minutes: a details screen asks on every open, and this is a full scan. */ + suspend fun snapshot(): Snapshot = mutex.withLock { + cached?.takeIf { System.currentTimeMillis() - it.takenAt < MAX_AGE_MS }?.let { return it } + val dao = db.getTagsDao() + Snapshot( + documentFrequency = dao.countMangaPerTag().associate { it.title to it.count }, + total = dao.countTaggedManga(), + takenAt = System.currentTimeMillis(), + ).also { cached = it } + } + + class Snapshot( + private val documentFrequency: Map, + private val total: Int, + val takenAt: Long, + ) { + + /** How many stored manga carry [title]. */ + fun frequency(title: String): Int = documentFrequency[SuggestionRanking.normalizeTag(title)] ?: 0 + + /** Inverse document frequency, smoothed so an unseen tag is rare rather than infinite. */ + fun rarity(title: String): Double = ln((total + 1.0) / (frequency(title) + 1.0)) + 1.0 + } + + private companion object { + const val MAX_AGE_MS = 10 * 60 * 1000L + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt index 3e93e41fc6..a0ae643e7c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt @@ -39,6 +39,7 @@ import dagger.assisted.AssistedInject import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.exceptions.CloudFlareException import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler +import io.github.landwarderer.futon.core.model.MangaHistory import io.github.landwarderer.futon.core.model.distinctById import io.github.landwarderer.futon.core.model.getLocale import io.github.landwarderer.futon.core.model.isNsfw @@ -56,7 +57,6 @@ import io.github.landwarderer.futon.core.util.ext.getQuantityStringSafe import io.github.landwarderer.futon.core.util.ext.mangaSourceExtra import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.sanitize -import io.github.landwarderer.futon.core.util.ext.takeMostFrequent import io.github.landwarderer.futon.core.util.ext.toBitmapOrNull import io.github.landwarderer.futon.core.util.ext.trySetForeground import io.github.landwarderer.futon.explore.data.MangaSourcesRepository @@ -72,7 +72,9 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero import io.github.landwarderer.futon.settings.work.PeriodicWorkScheduler import io.github.landwarderer.futon.suggestions.domain.MangaSuggestion +import io.github.landwarderer.futon.suggestions.domain.SuggestionRanking import io.github.landwarderer.futon.suggestions.domain.SuggestionRepository +import io.github.landwarderer.futon.suggestions.domain.TagStatistics import io.github.landwarderer.futon.suggestions.domain.TagsBlacklist import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.channelFlow @@ -82,6 +84,7 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import java.time.Instant import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.math.pow @@ -101,6 +104,7 @@ class SuggestionsWorker @AssistedInject constructor( private val workManager: WorkManager, private val mangaRepositoryFactory: MangaRepository.Factory, private val sourcesRepository: MangaSourcesRepository, + private val tagStatistics: TagStatistics, ) : CoroutineWorker(appContext, params) { private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } @@ -178,16 +182,32 @@ class SuggestionsWorker @AssistedInject constructor( } private suspend fun doWorkImpl(): Int { - val seed = ( - historyRepository.getList(0, 20) + - favouritesRepository.getLastManga(20) - ).distinctById() + val history = historyRepository.getList(0, 20) + val favourites = favouritesRepository.getLastManga(20) + val favouriteIds = favourites.mapTo(HashSet()) { it.id } + // Opened, barely read and left for weeks: what the reader turned away from, not a taste to + // echo. A favourite is never that, however little of it has been read. + val droppedIds = history.filter { manga -> + manga.id !in favouriteIds && historyRepository.getOne(manga)?.isDropped() == true + }.mapTo(HashSet()) { it.id } + val seed = (history + favourites).distinctById().filterNot { it.id in droppedIds } val sources = getSources() if (seed.isEmpty() || sources.isEmpty()) { return 0 } val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD) - val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) + // The reader's most telling tags rather than their most frequent: frequency alone always + // crowns `action`, and a source searched for `action` answers with its best-sellers. + val statistics = runCatchingCancellable { tagStatistics.snapshot() } + .onFailure { it.printStackTraceDebug("SuggestionsWorker::tagStatistics") } + .getOrNull() + val tags = SuggestionRanking.distinctiveTags( + seedTags = seed.flatMap { manga -> + manga.tags.filterNot { it in tagsBlacklist }.map { x -> x.title } + }, + rarity = { title -> statistics?.rarity(title) ?: 1.0 }, + limit = 10, + ) val semaphore = Semaphore(MAX_PARALLELISM) val producer = channelFlow { @@ -269,14 +289,26 @@ class SuggestionsWorker @AssistedInject constructor( val availableOrders = repository.sortOrders val order = preferredSortOrders.first { it in availableOrders } val availableTags = repository.getFilterOptions().availableTags - val tag = tags.firstNotNullOfOrNull { title -> + val matched = tags.mapNotNull { title -> availableTags.find { x -> x !in blacklist && x.title.almostEquals(title, TAG_EQ_THRESHOLD) } - } - val list = repository.getList( + }.distinct() + // A source carrying none of the reader's tags used to be searched with no tag at all, which + // is its whole catalogue, newest or most popular first - nothing to do with the reader. + val first = matched.firstOrNull() ?: return@runCatchingCancellable emptyList() + // Two telling tags together find what is actually alike; one alone casts a wide net + val narrow = if (repository.filterCapabilities.isMultipleTagsSupported) matched.take(2).toSet() else setOf(first) + var list = repository.getList( offset = 0, order = order, - filter = MangaListFilter(tags = setOfNotNull(tag)), + filter = MangaListFilter(tags = narrow), ).asArrayList() + if (list.isEmpty() && narrow.size > 1) { + list = repository.getList( + offset = 0, + order = order, + filter = MangaListFilter(tags = setOf(first)), + ).asArrayList() + } if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw() } } @@ -396,6 +428,8 @@ class SuggestionsWorker @AssistedInject constructor( return (weight / maxWeight).pow(2.0).toFloat() } + private fun MangaHistory.isDropped(): Boolean = SuggestionRanking.isDropped(percent, updatedAt, Instant.now()) + private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { forEachIndexed { i, t -> if (t.almostEquals(element, threshold)) { @@ -472,11 +506,12 @@ class SuggestionsWorker @AssistedInject constructor( const val RATING_MIN = 0.5f const val SETTINGS_ACTION_CODE = 4 + // Popularity last: it hands back the same best-sellers whatever the reader likes val preferredSortOrders = listOf( SortOrder.UPDATED, SortOrder.NEWEST, - SortOrder.POPULARITY, SortOrder.RATING, + SortOrder.POPULARITY, ) } } diff --git a/app/src/test/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRankingTest.kt b/app/src/test/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRankingTest.kt new file mode 100644 index 0000000000..3cf59924db --- /dev/null +++ b/app/src/test/kotlin/io/github/landwarderer/futon/suggestions/domain/SuggestionRankingTest.kt @@ -0,0 +1,61 @@ +package io.github.landwarderer.futon.suggestions.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Duration +import java.time.Instant + +class SuggestionRankingTest { + + private val now = Instant.parse("2026-10-01T00:00:00Z") + + @Test + fun `normalizeTag trims and lowercases`() { + assertEquals("action", SuggestionRanking.normalizeTag(" ACTION ")) + } + + @Test + fun `rare recurring tags beat common ones`() { + val rarity = mapOf("action" to 1.0, "villainess" to 4.0, "romance" to 1.5) + val seed = listOf("Action", "Action", "Action", "Villainess", "villainess", "Romance", "Romance", "Oddity") + val result = SuggestionRanking.distinctiveTags(seed, { rarity[it.lowercase()] ?: 10.0 }, limit = 10) + assertEquals(listOf("Villainess", "Action", "Romance", "Oddity"), result) + } + + @Test + fun `single occurrence tags come after recurring ones`() { + val seed = listOf("Common", "Common", "Rare") + val result = SuggestionRanking.distinctiveTags(seed, { if (it == "Rare") 100.0 else 1.0 }, limit = 10) + assertEquals(listOf("Common", "Rare"), result) + } + + @Test + fun `distinctiveTags skips blank and respects limit`() { + val result = SuggestionRanking.distinctiveTags(listOf(" ", "a", "b", "c"), { 1.0 }, limit = 2) + assertEquals(2, result.size) + assertFalse(result.any { it.isBlank() }) + } + + @Test + fun `weightedOverlap weighs rare tags higher`() { + val rarity = { t: String -> if (t == "villainess") 5.0 else 1.0 } + val seed = setOf("action", "villainess") + val rareMatch = SuggestionRanking.weightedOverlap(seed, setOf("villainess", "comedy"), rarity) + val commonMatch = SuggestionRanking.weightedOverlap(seed, setOf("action", "comedy"), rarity) + assertTrue(rareMatch > commonMatch) + assertEquals(1.0, SuggestionRanking.weightedOverlap(seed, seed, rarity), 1e-9) + assertEquals(0.0, SuggestionRanking.weightedOverlap(seed, emptySet(), rarity), 1e-9) + } + + @Test + fun `dropped means barely read and left for weeks`() { + val old = now - Duration.ofDays(30) + val recent = now - Duration.ofDays(3) + assertTrue(SuggestionRanking.isDropped(0.05f, old, now)) + assertFalse(SuggestionRanking.isDropped(0.05f, recent, now)) + assertFalse(SuggestionRanking.isDropped(0.5f, old, now)) + assertFalse(SuggestionRanking.isDropped(-1f, old, now)) + } +} From bcb45d0edc36439cfef7a629cbc1bca0567328a1 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 08/27] fix(webtoon): fix broken page sizing, page joins and pull-to-switch-chapter * fix(webtoon): fix zero-height, infinite-scale and cut-off pages Ported from Kotatsu-Redo 9.8.3/9.8.5. * fix(webtoon): rewrite pull-to-switch-chapter gesture Replace the EdgeEffect-based pull effect with a touch tracker in dispatchTouchEvent that only starts a pull when the list and the edge page's inner image scroll are really at the first/last edge. The edge glow is disabled while the gesture is enabled. The feedback chip now fades from 0.15 to full alpha and switches to an outlined background once the threshold is reached, and hides immediately on release. Ported from Kotatsu-Redo 45455ee18. * fix(webtoon): stop pages being cut off at page joins Walk every page a scroll reaches instead of only the first two, so a fast fling over short pages can no longer carry a tall page past the viewport edge with its image unscrolled. Re-pin all pages' inner scroll after every layout (decode, zoom, rotation), and place a page that finishes decoding after the list has already scrolled it partly off screen without jumping to its end or moving what is on screen. Ported from Kotatsu-Redo 9.8.5 (9fb36ac74). --- .../ui/pager/webtoon/WebtoonFrameLayout.kt | 3 + .../reader/ui/pager/webtoon/WebtoonHolder.kt | 17 +- .../ui/pager/webtoon/WebtoonImageView.kt | 59 ++- .../ui/pager/webtoon/WebtoonReaderFragment.kt | 43 ++- .../ui/pager/webtoon/WebtoonRecyclerView.kt | 355 +++++++++++++----- .../drawable/bg_reader_indicator_ready.xml | 15 + app/src/main/res/values/ids.xml | 1 + 7 files changed, 372 insertions(+), 121 deletions(-) create mode 100644 app/src/main/res/drawable/bg_reader_indicator_ready.xml diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonFrameLayout.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonFrameLayout.kt index e9b7e8c594..1cc52c8056 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonFrameLayout.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonFrameLayout.kt @@ -12,6 +12,9 @@ class WebtoonFrameLayout @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { + /** Set between the image becoming ready and [WebtoonRecyclerView] placing it. */ + var isRepinPending = false + private var _target: WebtoonImageView? = null val target: WebtoonImageView get() = _target ?: findViewById(R.id.ssiv).also { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonHolder.kt index cc94427eb3..0ff300e900 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -35,15 +35,14 @@ class WebtoonHolder( override fun onReady() { binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() - with(binding.ssiv) { - scrollTo( - when { - scrollToRestore != 0 -> scrollToRestore - itemView.top < 0 -> getScrollRange() - else -> 0 - }, - ) - scrollToRestore = 0 + val restore = scrollToRestore + scrollToRestore = 0 + val recyclerView = itemView.parent as? WebtoonRecyclerView + when { + restore != 0 -> binding.ssiv.scrollTo(restore) + // Also reached on every settings change, so this must leave an already placed page alone + recyclerView != null -> recyclerView.onPageReady(itemView as WebtoonFrameLayout) + else -> binding.ssiv.scrollTo(binding.ssiv.getScroll()) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonImageView.kt index 9f79edaf64..5b7e0e055f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -9,7 +9,6 @@ import androidx.core.view.ancestors import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import io.github.landwarderer.futon.core.util.ext.resolveDp -import kotlin.math.roundToInt class WebtoonImageView @JvmOverloads constructor( context: Context, @@ -52,7 +51,9 @@ class WebtoonImageView @JvmOverloads constructor( if (!isReady) { return 0 } - val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt() + // Rounded exactly as onMeasure rounds the view height. Rounding these two differently gave short + // pages a 1px inner scroll and tall ones a last row that never showed - a hairline at the join. + val totalHeight = (sHeight.toDouble() / sWidth.toDouble() * width).toInt() return (totalHeight - height).coerceAtLeast(0) } @@ -92,7 +93,15 @@ class WebtoonImageView @JvmOverloads constructor( } } desiredWidth = desiredWidth.coerceAtLeast(suggestedMinimumWidth) - desiredHeight = desiredHeight.coerceAtLeast(suggestedMinimumHeight).coerceAtMost(parentHeight()) + desiredHeight = desiredHeight.coerceAtLeast(suggestedMinimumHeight) + // A page is never taller than the viewport - it scrolls internally instead - but only cap when + // the viewport height is actually known. parentHeight() is 0 whenever this view is measured + // without a RecyclerView above it (while detached, or before being attached), and capping to + // that collapses the page to zero height, which reads as two pages running into each other. + val viewportHeight = parentHeight() + if (viewportHeight > 0) { + desiredHeight = desiredHeight.coerceAtMost(viewportHeight) + } setMeasuredDimension(desiredWidth, desiredHeight) } @@ -100,6 +109,7 @@ class WebtoonImageView @JvmOverloads constructor( super.onDownSamplingChanged() if (isReady) { adjustScale() + applyScroll() onImageEventListener.onReady() } } @@ -107,17 +117,60 @@ class WebtoonImageView @JvmOverloads constructor( override fun onReady() { super.onReady() adjustScale() + applyScroll() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + applyScroll() + } + + /** + * Shows the image at [scrollPos], whatever SSIV would have done on its own. + * + * SSIV centres a newly decoded image, and keeps that centre when the view is resized. A webtoon + * page is positioned from its top instead, so wherever the image becomes ready or the view + * changes size, the position has to be applied again here. This was missing for pages + * RecyclerView prefetches: they decode while detached and still zero-sized, the scroll asked for + * then could not be applied, and the page came on screen showing its middle - everything above + * that line skipped, which is the cut readers saw where two pages meet. + */ + private fun applyScroll() { + if (canScale()) { + scrollToInternal(scrollPos.coerceIn(0, getScrollRange())) + } } + /** + * [getScrollRange] reports `0` both for an image shorter than the view and for one that has not been + * decoded yet, and callers cannot tell those apart. Without this guard the second case divides by + * `sWidth == 0`: float division yields `Infinity` rather than throwing, so `minScale` and `maxScale` + * both become infinite, and SSIV clamps the image to that scale as soon as it does decode - the page + * then renders as a hugely magnified crop, silently and only in webtoon mode. + */ + private fun canScale(): Boolean = isReady && sWidth > 0 && sHeight > 0 && width > 0 + private fun scrollToInternal(pos: Int) { + if (!canScale()) { + // Kept rather than dropped, so applyScroll can put it into effect once the view can + scrollPos = pos.coerceAtLeast(0) + return + } minScale = width / sWidth.toFloat() maxScale = minScale + // The width-fit mode too, not just its value: a page decoded while still zero-sized never got + // it from adjustScale, and SSIV's default would fit the whole page inside the view instead - + // a long strip shrunk to a sliver in the middle of the screen + minimumScaleType = SCALE_TYPE_CUSTOM scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) setScaleAndCenter(minScale, ct) } private fun adjustScale() { + if (!canScale()) { + return + } minScale = width / sWidth.toFloat() maxScale = minScale minimumScaleType = SCALE_TYPE_CUSTOM diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index 2d29be2408..04602d0493 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -223,14 +223,14 @@ class WebtoonReaderFragment : BaseReaderFragment() } override fun onPullTriggeredTop() { - (viewBinding ?: return).feedbackTop.fadeOut() + (viewBinding ?: return).feedbackTop.hideFeedback() if (canGoPrev) { viewModel.switchChapterBy(-1) } } override fun onPullTriggeredBottom() { - (viewBinding ?: return).feedbackBottom.fadeOut() + (viewBinding ?: return).feedbackBottom.hideFeedback() if (canGoNext) { viewModel.switchChapterBy(1) } @@ -238,8 +238,8 @@ class WebtoonReaderFragment : BaseReaderFragment() override fun onPullCancelled() { viewBinding?.apply { - feedbackTop.fadeOut() - feedbackBottom.fadeOut() + feedbackTop.hideFeedback() + feedbackBottom.hideFeedback() } } @@ -255,21 +255,36 @@ class WebtoonReaderFragment : BaseReaderFragment() private fun TextView.updateFeedback(progress: Float) { val clamped = progress.coerceIn(0f, 1.2f) - this.alpha = clamped.coerceAtMost(1f) - this.scaleX = 0.9f + 0.1f * clamped.coerceAtMost(1f) - this.scaleY = this.scaleX + val isReady = clamped >= 1f + val wasReady = getTag(R.id.tag_pull_feedback_ready) as? Boolean ?: false + if (wasReady != isReady) { + setTag(R.id.tag_pull_feedback_ready, isReady) + setBackgroundResource( + if (isReady) { + R.drawable.bg_reader_indicator_ready + } else { + R.drawable.bg_reader_indicator + }, + ) + } + animate().cancel() + alpha = if (clamped > 0f) { + (0.15f + 0.85f * clamped.coerceAtMost(1f)).coerceAtMost(1f) + } else { + 0f + } + val scale = 0.9f + 0.1f * clamped.coerceAtMost(1f) + scaleX = scale + scaleY = scale } - private fun TextView.fadeOut() { - animate().alpha(0f).setDuration(150L).start() + private fun TextView.hideFeedback() { + animate().cancel() + alpha = 0f } private fun TextView.setFeedbackText(text: CharSequence) { - if (this.alpha <= 0f && text.isNotEmpty()) { - this.alpha = 0f - this.text = text - animate().alpha(1f).setDuration(120L).start() - } else { + if (this.text != text) { this.text = text } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonRecyclerView.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonRecyclerView.kt index 6f1d3610cb..79c873505c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/webtoon/WebtoonRecyclerView.kt @@ -3,6 +3,7 @@ package io.github.landwarderer.futon.reader.ui.pager.webtoon import android.content.Context import android.graphics.Canvas import android.util.AttributeSet +import android.view.MotionEvent import android.view.View import android.widget.EdgeEffect import androidx.core.view.ViewCompat.TYPE_TOUCH @@ -12,11 +13,11 @@ import androidx.core.view.isNotEmpty import androidx.core.view.iterator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_BOTTOM -import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_TOP import java.util.Collections import java.util.LinkedList import java.util.WeakHashMap +import kotlin.math.abs +import kotlin.math.sign class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 @@ -27,17 +28,25 @@ class WebtoonRecyclerView @JvmOverloads constructor( private val detachedViews = Collections.newSetFromMap(WeakHashMap()) private var isFixingScroll = false + /** Sign of the last scroll the reader made - drag, fling or tap-scroll - and not of [repinPage]'s. */ + private var scrollDirection = 0 + + private val pullGestureTracker = PullGestureTracker() + var isPullGestureEnabled: Boolean = false set(value) { if (field != value) { field = value setEdgeEffectFactory( if (value) { - PullEffect.Factory() + NoOpEdgeEffectFactory() } else { EdgeEffectFactory() }, ) + if (!value) { + pullGestureTracker.reset(notifyListener = true) + } } } var pullThreshold: Float = 0.3f @@ -57,6 +66,20 @@ class WebtoonRecyclerView @JvmOverloads constructor( detachedViews.remove(child) } + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (isPullGestureEnabled && pullListener != null) { + pullGestureTracker.onTouchEvent(ev) + } else { + pullGestureTracker.reset(notifyListener = false) + } + return super.dispatchTouchEvent(ev) + } + + override fun onDetachedFromWindow() { + pullGestureTracker.reset(notifyListener = true) + super.onDetachedFromWindow() + } + override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH) override fun startNestedScroll(axes: Int, type: Int): Boolean = isNotEmpty() @@ -75,6 +98,9 @@ class WebtoonRecyclerView @JvmOverloads constructor( offsetInWindow: IntArray?, type: Int ): Boolean { + if (dy != 0) { + scrollDirection = dy.sign + } val consumedY = consumeVerticalScroll(dy) if (consumed != null) { consumed[0] = 0 @@ -84,44 +110,41 @@ class WebtoonRecyclerView @JvmOverloads constructor( return consumedY != 0 || dy == 0 } + /** + * Splits a scroll between the list and the pages' own scrolling. + * + * A page taller than the viewport is laid out viewport-sized and scrolls its image inside itself, so + * pages only join up while each one's inner scroll matches where it sits: at its start while its top + * edge is on screen, at its end while its bottom edge is, and free only while it fills the viewport. + * A page therefore scrolls inside itself only while pinned to the edge the content moves away from - + * its top when scrolling down, its bottom when scrolling up - and every page the scroll reaches is + * walked, not just the first two, so a fast fling over short pages cannot carry a tall one past the + * edge with its image unscrolled, which is what cuts a page off mid-panel. + * + * @return the part of [dy] taken up inside pages; the list scrolls by the rest + */ private fun consumeVerticalScroll(dy: Int): Int { - if (isEmpty()) { + if (dy == 0 || isEmpty()) { return 0 } - when { - dy > 0 -> { - val child = getChildAt(0) as WebtoonFrameLayout - var consumedByChild = child.dispatchVerticalScroll(dy) - if (consumedByChild < dy) { - if (childCount > 1) { - val nextChild = getChildAt(1) as WebtoonFrameLayout - val unconsumed = - dy - consumedByChild - nextChild.top //will be consumed by scroll - if (unconsumed > 0) { - consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) - } - } - } - return consumedByChild + val distance = abs(dy) + var consumed = 0 + var listScroll = 0 + for (i in 0 until childCount) { + val child = getChildAt(if (dy > 0) i else childCount - 1 - i) as? WebtoonFrameLayout ?: continue + // How far the list must still move before this page is pinned to the leading edge + val toPin = (if (dy > 0) child.top else height - child.bottom) - listScroll + if (toPin < 0) { + continue // already past that edge, so it is at its end and has nothing left to scroll } - - dy < 0 -> { - val child = getChildAt(childCount - 1) as WebtoonFrameLayout - var consumedByChild = child.dispatchVerticalScroll(dy) - if (consumedByChild > dy) { - if (childCount > 1) { - val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout - val unconsumed = - dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll - if (unconsumed < 0) { - consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) - } - } - } - return consumedByChild + if (toPin > distance - consumed - listScroll) { + break // this scroll ends before the page gets there, and so do the ones after it } + listScroll += toPin + val remaining = distance - consumed - listScroll + consumed += abs(child.dispatchVerticalScroll(if (dy > 0) remaining else -remaining)) } - return 0 + return if (dy > 0) consumed else -consumed } fun addOnPageScrollListener(listener: OnWebtoonScrollListener) { @@ -149,34 +172,85 @@ class WebtoonRecyclerView @JvmOverloads constructor( } } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + // Re-measuring moves pages and resizes them - a decoded image, zoom, rotation - without scrolling + updateChildrenScroll() + } + + /** + * Brings every page's inner scroll back in line with where the page sits: at its start when its top + * edge is on screen, at its end when its bottom edge is. See [consumeVerticalScroll]. + * + * Snapping can move an image, but leaving a page out of line shows its middle against the next page. + */ fun updateChildrenScroll() { if (isFixingScroll) { return } isFixingScroll = true - for (child in this) { - val ssiv = (child as WebtoonFrameLayout).target - if (adjustScroll(child, ssiv)) { - break + try { + for (child in this) { + val frame = child as? WebtoonFrameLayout ?: continue + if (frame.isRepinPending) { + continue // repinPage() is about to place it without moving what is on screen + } + val ssiv = frame.target + val range = ssiv.getScrollRange() + val target = when { + child.top > 0 -> 0 + child.bottom < height -> range + else -> ssiv.getScroll().coerceIn(0, range) + } + if (ssiv.getScroll() != target) { + ssiv.scrollTo(target) + } } + } finally { + isFixingScroll = false } - isFixingScroll = false } - private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when { - child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> { - val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll()) - ssiv.scrollBy(distance) - true - } + /** + * Called when a page's image has been decoded. + * + * Until then the page is a viewport-sized placeholder with nothing to scroll, so the list may have + * scrolled it partly out of view. Deciding the inner scroll from position alone - end of the image if + * the page is above the top - jumps the reader to the end of a page they have barely started, or + * shows its middle against the next page. Posted because SSIV reports readiness from `onDraw`. + */ + fun onPageReady(child: WebtoonFrameLayout) { + child.isRepinPending = true + post { repinPage(child) } + } - child.top > 0 && ssiv.getScroll() > 0 -> { - val distance = minOf(child.top, ssiv.getScroll()) - ssiv.scrollBy(-distance) - true + /** + * Puts a just-decoded page in line. + * + * Only one case moves the list: reading downwards into a page the list carried past the viewport + * top while it was a placeholder. The reader is part-way down it, so the row at the viewport top + * stays put and the page is pinned under it - whatever its inner scroll cannot take up, the list + * does. Everything else is squared up by position alone: a page decoded off screen must not be + * pulled into view, and one arriving from above while scrolling up has its end against the page + * below, which is where position puts it. + */ + private fun repinPage(child: WebtoonFrameLayout) { + child.isRepinPending = false + if (child.parent !== this) { + return } - - else -> false + val ssiv = child.target + val range = ssiv.getScrollRange() + val scroll = ssiv.getScroll().coerceIn(0, range) + val isOnScreen = child.top < height && child.bottom > 0 + if (isOnScreen && scrollDirection >= 0 && child.top < 0 && scroll < range) { + val target = minOf(scroll - child.top, range) + ssiv.scrollTo(target) + // RecyclerView.scrollBy moves only the list: it does not route through consumeVerticalScroll + scrollBy(0, scroll - target) + } + // Squares up this page by position, and anything the list moving less than asked left out of line + updateChildrenScroll() } private class WebtoonScrollDispatcher { @@ -203,68 +277,159 @@ class WebtoonRecyclerView @JvmOverloads constructor( } } - private class PullEffect( - view: RecyclerView, - private val direction: Int, - private val pullThreshold: Float, - private val pullListener: OnPullGestureListener, - ) : EdgeEffect(view.context) { + private class NoOpEdgeEffectFactory : EdgeEffectFactory() { + + override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect = object : EdgeEffect(view.context) { + override fun draw(canvas: Canvas): Boolean = false + } + } + + private inner class PullGestureTracker { + + private var edge = PullEdge.NONE + private var lastY = 0f + private var distancePx = 0f + private var isTracking = false - private var pullProgressTop: Float = 0f - private var pullProgressBottom: Float = 0f + fun onTouchEvent(ev: MotionEvent) { + val listener = pullListener ?: return + if (!isPullGestureEnabled) { + return + } + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + reset(notifyListener = false) + isTracking = true + lastY = ev.y + } + + MotionEvent.ACTION_MOVE -> { + if (!isTracking) { + return + } + val y = ev.y + val dy = y - lastY + lastY = y + if (dy != 0f) { + handleMove(dy, listener) + } + } - override fun onPull(deltaDistance: Float) { - val sign = if (direction == DIRECTION_TOP) 1f else if (direction == DIRECTION_BOTTOM) 1f else 0f - if (sign != 0f) onPull(deltaDistance, 0.5f) + MotionEvent.ACTION_UP -> { + if (isTracking) { + finish(listener, cancelled = false) + } + } + + MotionEvent.ACTION_CANCEL -> { + if (isTracking) { + finish(listener, cancelled = true) + } + } + } } - override fun onPull(deltaDistance: Float, displacement: Float) { - if (direction == DIRECTION_TOP) { - pullProgressTop = (pullProgressTop + deltaDistance).coerceAtLeast(0f) - pullListener.onPullProgressTop(pullProgressTop / pullThreshold) - } else if (direction == DIRECTION_BOTTOM) { - pullProgressBottom = (pullProgressBottom + deltaDistance).coerceAtLeast(0f) - pullListener.onPullProgressBottom(pullProgressBottom / pullThreshold) + fun reset(notifyListener: Boolean) { + val listener = pullListener + edge = PullEdge.NONE + distancePx = 0f + lastY = 0f + isTracking = false + if (notifyListener && listener != null) { + listener.onPullProgressTop(0f) + listener.onPullProgressBottom(0f) + listener.onPullCancelled() } } - override fun onRelease() { - var triggered = false - if (direction == DIRECTION_TOP) { - if (pullProgressTop >= pullThreshold) { - pullListener.onPullTriggeredTop() - triggered = true + private fun handleMove(dy: Float, listener: OnPullGestureListener) { + if (height <= 0) { + return + } + if (edge == PullEdge.NONE) { + edge = when { + dy > 0f && isAtAbsoluteTop() -> PullEdge.TOP + dy < 0f && isAtAbsoluteBottom() -> PullEdge.BOTTOM + else -> PullEdge.NONE } - pullProgressTop = 0f - pullListener.onPullProgressTop(0f) - } else if (direction == DIRECTION_BOTTOM) { - if (pullProgressBottom >= pullThreshold) { - pullListener.onPullTriggeredBottom() - triggered = true + if (edge == PullEdge.NONE) { + return } - pullProgressBottom = 0f - pullListener.onPullProgressBottom(0f) } - if (!triggered) { - pullListener.onPullCancelled() + + val delta = when (edge) { + PullEdge.TOP -> dy + PullEdge.BOTTOM -> -dy + else -> 0f + } + + distancePx = (distancePx + delta).coerceAtLeast(0f) + val progress = (distancePx / (height * pullThreshold).coerceAtLeast(1f)) + when (edge) { + PullEdge.TOP -> listener.onPullProgressTop(progress) + PullEdge.BOTTOM -> listener.onPullProgressBottom(progress) + else -> Unit + } + if (distancePx <= 0f) { + edge = PullEdge.NONE } } - override fun draw(canvas: Canvas?): Boolean = false + private fun finish(listener: OnPullGestureListener, cancelled: Boolean) { + val progress = if (height > 0) (distancePx / (height * pullThreshold).coerceAtLeast(1f)) else 0f + val edge = this.edge + this.edge = PullEdge.NONE + distancePx = 0f + lastY = 0f + isTracking = false + + listener.onPullProgressTop(0f) + listener.onPullProgressBottom(0f) + + when { + cancelled -> listener.onPullCancelled() + edge == PullEdge.TOP && progress >= 1f -> listener.onPullTriggeredTop() + edge == PullEdge.BOTTOM && progress >= 1f -> listener.onPullTriggeredBottom() + else -> listener.onPullCancelled() + } + } - class Factory : EdgeEffectFactory() { + private fun isAtAbsoluteTop(): Boolean { + val lm = layoutManager as? LinearLayoutManager ?: return !canScrollVertically(-1) + if (lm.findFirstVisibleItemPosition() != 0) { + return false + } + val child = getChildAt(0) as? WebtoonFrameLayout ?: return false + if (child.top < 0) { + return false + } + return child.target.getScroll() <= 0 + } - override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect { - val pullListener = (view as? WebtoonRecyclerView)?.pullListener - return if (pullListener != null) { - PullEffect(view, direction, view.pullThreshold, pullListener) - } else { - super.createEdgeEffect(view, direction) - } + private fun isAtAbsoluteBottom(): Boolean { + val adapter = adapter ?: return false + val lm = layoutManager as? LinearLayoutManager ?: return !canScrollVertically(1) + if (lm.findLastVisibleItemPosition() != adapter.itemCount - 1) { + return false + } + if (childCount <= 0) { + return false } + val child = getChildAt(childCount - 1) as? WebtoonFrameLayout ?: return false + if (child.bottom > height) { + return false + } + val ssiv = child.target + return ssiv.getScroll() >= ssiv.getScrollRange() } } + private enum class PullEdge { + NONE, + TOP, + BOTTOM, + } + interface OnWebtoonScrollListener { fun onScrollChanged( diff --git a/app/src/main/res/drawable/bg_reader_indicator_ready.xml b/app/src/main/res/drawable/bg_reader_indicator_ready.xml new file mode 100644 index 0000000000..2ecc871f0d --- /dev/null +++ b/app/src/main/res/drawable/bg_reader_indicator_ready.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index e8504f0926..ba677b56f9 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -7,6 +7,7 @@ + From f337f6ba94f355d64ea8e1b0186babf7ec8dae40 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:26 +0000 Subject: [PATCH 09/27] fix(tracker): correct new-chapter counting and stop resetting tracks * fix(tracker): stop resetting tracks shared by history and favourites, page past update-disabled sources Ported from Kotatsu-Redo 9.8.0/9.8.1. * fix(tracker): credit chapters released since the last tracked chapter Opening the reader on an older chapter advanced last_chapter_id to the newest chapter without counting chapters released since the previous check, so they never showed up in Updates. Also cap the stored new chapters counter at the branch chapter count. Ported from Kotatsu-Redo 9.7.4 (eb2dd236d). * fix(tracker): fall back to date and history anchors when comparing When the tracked last chapter id is no longer in the fresh chapter list (sources rotating chapter urls), compare by the last chapter upload date, then by the history chapter, and only then re-baseline silently. The first check of a track anchors on the history chapter instead of swallowing chapters released since the user last read. Ported from Kotatsu-Redo 9.7.4 (eb2dd236d) / 9.7.8 (f34b597c1). * fix(tracker): drop already read and stale chapters from updates Chapters at or before the reading position (also taking similar-titled manga read from another source into account) or uploaded more than 90 days ago are not reported as new. Adds HistoryRepository.findSimilarByTitle. Ported from Kotatsu-Redo 9.7.8 (f34b597c1) / 9.8.1 (cb430d3a6). * fix(tracker): skip long inactive tracks and clear stale counters Tracks without a chapter, read or favourite in the last 90 days are left out of regular checks but still re-checked weekly, so a hiatus series is not excluded forever. Unread counters of tracks whose last chapter is older than 90 days are cleared on gc. Ported from Kotatsu-Redo 9.7.8 (f34b597c1) / 9.8.0 (b1483ebdd). * fix(tracker): rebase tracks on the history chapter when clearing counters Clearing all updates now also resets last_chapter_id to the history chapter (or 0 when there is none), so the tracker recomputes unread chapters from the reading position. Ported from Kotatsu-Redo 9.7.4 (eb2dd236d). * fix(tracker): treat manual checks as full runs without foreground service A manually triggered one-shot check now checks every track even when the system refuses to promote the worker to a foreground service. Ported from Kotatsu-Redo 9.7.4 (eb2dd236d). * fix(tracker): one-shot migration for tracks advanced past unread chapters The reader-side new chapters check used to move last_chapter_id to the newest chapter without crediting chapters the user had not read. On first start with network, credit chapters after the history chapter for tracks with no new chapters whose history differs from last_chapter_id, then refresh history chapter counts of tracks with new chapters so they no longer show as completed. Gated by two SharedPreferences flags; no DB schema change. Ported from Kotatsu-Redo 9.7.4 (eb2dd236d) / 9.8.0 (b1483ebdd). --- .../futon/core/prefs/AppSettings.kt | 10 + .../futon/history/data/HistoryRepository.kt | 35 +++ .../settings/work/WorkScheduleManager.kt | 8 + .../futon/tracker/data/TracksDao.kt | 42 +++- .../tracker/domain/CheckNewChaptersUseCase.kt | 207 +++++++++++++++--- .../futon/tracker/domain/GetTracksUseCase.kt | 19 +- .../domain/TrackerUnstuckMigrationUseCase.kt | 199 +++++++++++++++++ .../tracker/domain/TrackingRepository.kt | 82 +++++-- .../futon/tracker/work/TrackWorker.kt | 8 +- 9 files changed, 549 insertions(+), 61 deletions(-) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackerUnstuckMigrationUseCase.kt 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 a35af1e84c..9b11f12ea0 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 @@ -255,6 +255,14 @@ class AppSettings internal constructor( val trackerDownloadStrategy: TrackerDownloadStrategy get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED) + var isTrackerUnstuckMigrationDone: Boolean + get() = prefs.getBoolean(KEY_TRACKER_UNSTUCK_MIGRATION, false) + set(value) = prefs.edit { putBoolean(KEY_TRACKER_UNSTUCK_MIGRATION, value) } + + var isTrackerProgressRefreshDone: Boolean + get() = prefs.getBoolean(KEY_TRACKER_PROGRESS_REFRESH, false) + set(value) = prefs.edit { putBoolean(KEY_TRACKER_PROGRESS_REFRESH, value) } + var notificationSound: Uri get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() ?: Settings.System.DEFAULT_NOTIFICATION_URI @@ -864,6 +872,8 @@ class AppSettings internal constructor( const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw" const val KEY_TRACKER_DOWNLOAD = "tracker_download" + const val KEY_TRACKER_UNSTUCK_MIGRATION = "tracker_unstuck_migration" + const val KEY_TRACKER_PROGRESS_REFRESH = "tracker_progress_refresh" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryRepository.kt index f79823f053..d32a88d525 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryRepository.kt @@ -27,6 +27,7 @@ import io.github.landwarderer.futon.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.levenshteinDistance import io.github.landwarderer.futon.scrobbling.common.domain.Scrobbler @@ -141,6 +142,25 @@ class HistoryRepository @Inject constructor( return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() } + /** + * Histories of other library manga whose title (or one of the alternative titles) is almost the same as + * [manga]'s, e.g. the same series read from another source. + */ + suspend fun findSimilarByTitle(manga: Manga, limit: Int): List { + val title = manga.title.trim() + if (title.length < MIN_TITLE_MATCH_LENGTH) { + return emptyList() + } + val result = ArrayList() + for (candidate in db.getMangaDao().searchByTitle("%$title%", limit)) { + if (candidate.manga.id == manga.id || !candidate.manga.title.matchesTitle(manga)) { + continue + } + db.getHistoryDao().find(candidate.manga.id)?.toMangaHistory()?.let(result::add) + } + return result + } + suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? { val entity = db.getHistoryDao().find(mangaId) ?: return null val fixedPercent = if (ReadingProgress.isCompleted(entity.percent)) 1f else entity.percent @@ -230,4 +250,19 @@ class HistoryRepository @Inject constructor( } private fun HistoryWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) + + private fun String.matchesTitle(manga: Manga): Boolean { + if (almostEquals(manga.title, TITLE_MATCH_THRESHOLD)) { + return true + } + return manga.altTitles.any { altTitle -> + altTitle.isNotBlank() && almostEquals(altTitle, TITLE_MATCH_THRESHOLD) + } + } + + private companion object { + + const val MIN_TITLE_MATCH_LENGTH = 4 + const val TITLE_MATCH_THRESHOLD = 0.12f + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt index 5451b94407..5122fd7b1e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt @@ -6,10 +6,12 @@ import io.github.landwarderer.futon.core.util.ext.processLifecycleScope import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker import io.github.landwarderer.futon.download.ui.worker.DownloadWorker import io.github.landwarderer.futon.suggestions.ui.SuggestionsWorker +import io.github.landwarderer.futon.tracker.domain.TrackerUnstuckMigrationUseCase import io.github.landwarderer.futon.tracker.work.TrackWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton @Singleton @@ -18,6 +20,7 @@ class WorkScheduleManager @Inject constructor( private val suggestionScheduler: SuggestionsWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler, + private val trackerUnstuckMigrationProvider: Provider, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -52,6 +55,11 @@ class WorkScheduleManager @Inject constructor( updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) DownloadSchedulerWorker.scheduleAlarm(downloadScheduler.workManager, settings) } + processLifecycleScope.launch(Dispatchers.Default) { + // Recovery for tracks bugged by the previous reader-side new chapters check. + // Runs at most once per install (gated by settings flags). + trackerUnstuckMigrationProvider.get().runIfNeeded() + } } private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt index 6e83b6abd3..5946b01346 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt @@ -13,9 +13,35 @@ import io.github.landwarderer.futon.list.domain.ListFilterOption @Dao abstract class TracksDao : MangaQueryBuilder.ConditionCallback { + /** + * Tracks to check, least recently checked first. Tracks inactive for a while (no recent chapter, read or + * favourite) are skipped unless they were not checked since [staleCheckTime]. + */ @Transaction - @Query("SELECT * FROM tracks ORDER BY last_check_time ASC LIMIT :limit OFFSET :offset") - abstract suspend fun findAll(offset: Int, limit: Int): List + @Query( + """ + SELECT * FROM tracks + WHERE last_check_time = 0 + OR last_check_time <= :staleCheckTime + OR last_chapter_date = 0 + OR last_chapter_date >= :minActivityTime + OR EXISTS( + SELECT 1 FROM history + WHERE history.manga_id = tracks.manga_id + AND history.deleted_at = 0 + AND history.updated_at >= :minActivityTime + ) + OR EXISTS( + SELECT 1 FROM favourites + WHERE favourites.manga_id = tracks.manga_id + AND favourites.deleted_at = 0 + AND favourites.created_at >= :minActivityTime + ) + ORDER BY last_check_time ASC + LIMIT :limit OFFSET :offset + """, + ) + abstract suspend fun findAll(offset: Int, limit: Int, minActivityTime: Long, staleCheckTime: Long): List @Transaction @Query("SELECT * FROM tracks ORDER BY last_check_time DESC") @@ -58,12 +84,22 @@ abstract class TracksDao : MangaQueryBuilder.ConditionCallback { @Query("DELETE FROM tracks") abstract suspend fun clear() - @Query("UPDATE tracks SET chapters_new = 0") + /** + * Resets every unread counter and rebases each track on the reading position, so the next check reports the + * chapters after it again instead of losing them behind the newest chapter. + */ + @Query( + "UPDATE tracks SET chapters_new = 0, last_chapter_id = COALESCE(" + + "(SELECT chapter_id FROM history WHERE history.manga_id = tracks.manga_id AND history.deleted_at = 0), 0)", + ) abstract suspend fun clearCounters() @Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId") abstract suspend fun clearCounter(mangaId: Long) + @Query("UPDATE tracks SET chapters_new = 0 WHERE chapters_new > 0 AND last_chapter_date > 0 AND last_chapter_date < :minChapterDate") + abstract suspend fun clearStaleCounters(minChapterDate: Long) + @Query("DELETE FROM tracks WHERE manga_id = :mangaId") abstract suspend fun delete(mangaId: Long) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/CheckNewChaptersUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/CheckNewChaptersUseCase.kt index 3bd28d91d6..239070812a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/CheckNewChaptersUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/CheckNewChaptersUseCase.kt @@ -1,8 +1,6 @@ package io.github.landwarderer.futon.tracker.domain -import android.util.Log import coil3.request.CachePolicy -import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.core.model.getPreferredBranch import io.github.landwarderer.futon.core.model.isLocal import io.github.landwarderer.futon.core.parser.CachingMangaRepository @@ -10,14 +8,18 @@ import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.util.MultiMutex import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.toInstantOrNull +import io.github.landwarderer.futon.core.model.MangaHistory import io.github.landwarderer.futon.history.data.HistoryRepository +import io.github.landwarderer.futon.list.domain.ReadingProgress import io.github.landwarderer.futon.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.tracker.domain.model.MangaTracking import io.github.landwarderer.futon.tracker.domain.model.MangaUpdates import java.time.Instant +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -52,18 +54,25 @@ class CheckNewChaptersUseCase @Inject constructor( val branch = checkNotNull(details.chapters?.findById(currentChapterId)).branch val chapters = details.getChapters(branch) val chapterIndex = chapters.indexOfFirst { x -> x.id == currentChapterId } - val lastNewChapterIndex = chapters.size - track.newChapters val lastChapter = chapters.lastOrNull() + // Credit chapters that appeared since the previously tracked last chapter. + // Without this, opening the reader on an older chapter while a release + // dropped in the meantime would advance lastChapterId past the unseen + // chapter without ever flagging it as new, hiding it from the Updates tab. + val prevLastIndex = chapters.indexOfFirst { it.id == track.lastChapterId } + val addedSinceLastTrack = if (prevLastIndex >= 0) chapters.lastIndex - prevLastIndex else 0 + val effectiveNew = track.newChapters + addedSinceLastTrack + val lastNewChapterIndex = chapters.size - effectiveNew val tracking = MangaTracking( manga = details, lastChapterId = lastChapter?.id ?: 0L, lastCheck = Instant.now(), lastChapterDate = lastChapter?.uploadDate?.toInstantOrNull() ?: track.lastChapterDate, newChapters = when { - track.newChapters == 0 -> 0 - chapterIndex < 0 -> track.newChapters + effectiveNew == 0 -> 0 + chapterIndex < 0 -> effectiveNew chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex - else -> track.newChapters + else -> effectiveNew }, ) repository.mergeWith(tracking) @@ -74,7 +83,19 @@ class CheckNewChaptersUseCase @Inject constructor( private suspend fun invokeImpl(track: MangaTracking): MangaUpdates = runCatchingCancellable { val details = getFullManga(track.manga) - compare(track, details, getBranch(details, track.lastChapterId)) + val history = historyRepository.getOne(track.manga) + val historyChapterId = history?.chapterId ?: 0L + val branch = getBranch(details, track.lastChapterId, historyChapterId) + val updates = compare(track, details, branch, historyChapterId) + if (updates.isValid && updates.newChapters.isNotEmpty()) { + updates.filterReadAndStaleChapters( + chapters = details.getChapters(branch), + history = history, + similarHistories = historyRepository.findSimilarByTitle(details, SIMILAR_HISTORY_LIMIT), + ) + } else { + updates + } }.getOrElse { error -> MangaUpdates.Failure( manga = track.manga, @@ -84,10 +105,8 @@ class CheckNewChaptersUseCase @Inject constructor( repository.saveUpdates(updates) } - private suspend fun getBranch(manga: Manga, trackChapterId: Long): String? { - historyRepository.getOne(manga)?.let { - manga.chapters?.findById(it.chapterId) - }?.let { + private fun getBranch(manga: Manga, trackChapterId: Long, historyChapterId: Long): String? { + manga.chapters?.findById(historyChapterId)?.let { return it.branch } manga.chapters?.findById(trackChapterId)?.let { @@ -118,35 +137,159 @@ class CheckNewChaptersUseCase @Inject constructor( } /** - * The main functionality of tracker: check new chapters in [manga] comparing to the [track] + * The main functionality of tracker: check new chapters in [manga] comparing to the [track]. + * + * Comparison anchors, tried in order: + * 1. [MangaTracking.lastChapterId] - the tracker's own baseline. + * 2. [MangaTracking.lastChapterDate] - upload date of the last known chapter; robust to id + * churn (some sources rotate chapter URLs, which changes the derived ids on every fetch). + * 3. [historyChapterId] - the user's reading position; a last resort when the track has no + * usable id or date (e.g. a stale backup). + * + * If none of the anchors are usable we re-baseline silently (no notification). */ - private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success { + private fun compare( + track: MangaTracking, + manga: Manga, + branch: String?, + historyChapterId: Long, + ): MangaUpdates.Success { if (track.isEmpty()) { - // first check or manga was empty on last check + // First check (fresh install, restored backup or new manga): there is no tracker + // baseline, but the user's reading position is one. Anchoring on it surfaces the + // chapters released since the user last read, instead of silently swallowing them + // into the new baseline. + val chapters = manga.getChapters(branch) + if (historyChapterId != 0L && chapters.isNotEmpty()) { + compareAgainst(manga, branch, chapters, historyChapterId)?.let { + return it + } + } return MangaUpdates.Success(manga, branch, emptyList(), isValid = false) } - val chapters = requireNotNull(manga.getChapters(branch)) - if (BuildConfig.DEBUG && chapters.findById(track.lastChapterId) == null) { - Log.e("Tracker", "Chapter ${track.lastChapterId} not found") + val chapters = manga.getChapters(branch) + compareAgainst(manga, branch, chapters, track.lastChapterId)?.let { + return it } - val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } - return when { - newChapters.isEmpty() -> { - MangaUpdates.Success( - manga = manga, - branch = branch, - newChapters = emptyList(), - isValid = chapters.lastOrNull()?.id == track.lastChapterId, - ) + // lastChapterId is stale (not in the fresh list) -> prefer the date baseline. + compareByDate(manga, branch, chapters, track.lastChapterDate?.toEpochMilli() ?: 0L)?.let { + return it + } + // No usable id or date -> last resort: the user's reading position. + if (historyChapterId != 0L && historyChapterId != track.lastChapterId) { + compareAgainst(manga, branch, chapters, historyChapterId)?.let { + return it } + } + // Nothing usable; can't tell what's new. Re-baseline silently. + return MangaUpdates.Success(manga, branch, emptyList(), isValid = false) + } - newChapters.size == chapters.size -> { - MangaUpdates.Success(manga, branch, emptyList(), isValid = false) - } + /** + * Returns a result if [anchorChapterId] is a usable anchor in [chapters] (either it is the last + * chapter, or there are some chapters after it), or `null` if the anchor is absent from the list. + */ + private fun compareAgainst( + manga: Manga, + branch: String?, + chapters: List, + anchorChapterId: Long, + ): MangaUpdates.Success? { + val newChapters = chapters.takeLastWhile { x -> x.id != anchorChapterId } + return when { + newChapters.isEmpty() -> MangaUpdates.Success( + manga = manga, + branch = branch, + newChapters = emptyList(), + isValid = chapters.lastOrNull()?.id == anchorChapterId, + ) - else -> { - MangaUpdates.Success(manga, branch, newChapters, isValid = true) - } + newChapters.size == chapters.size -> null // anchor not found in the list + + else -> MangaUpdates.Success(manga, branch, newChapters, isValid = true) + } + } + + /** + * Date-based fallback: chapters uploaded strictly after [lastChapterDateMillis] are considered + * new. Returns `null` when the date is unusable (zero, or older than every chapter - which would + * flag the whole list and is more likely a data glitch than a real update). + */ + private fun compareByDate( + manga: Manga, + branch: String?, + chapters: List, + lastChapterDateMillis: Long, + ): MangaUpdates.Success? { + if (lastChapterDateMillis <= 0L) return null + val newChapters = chapters.filter { it.uploadDate > lastChapterDateMillis } + return when { + newChapters.isEmpty() -> MangaUpdates.Success(manga, branch, emptyList(), isValid = true) + newChapters.size == chapters.size -> null + else -> MangaUpdates.Success(manga, branch, newChapters, isValid = true) + } + } + + /** + * Drops "new" chapters the user has already read (here or in a similar-titled manga from + * another source) and chapters too old to be a genuine update. + */ + private fun MangaUpdates.Success.filterReadAndStaleChapters( + chapters: List, + history: MangaHistory?, + similarHistories: List, + ): MangaUpdates.Success { + if (!isValid || newChapters.isEmpty()) { + return this + } + val readChapters = maxOf( + history.getReadChaptersCount(chapters), + similarHistories.maxOfOrNull { it.estimatedReadChaptersCount() } ?: 0, + ) + val minChapterDate = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(MAX_STALE_CHAPTER_DAYS) + val filtered = newChapters.filter { chapter -> + chapter.isAfterReadPosition(chapters, readChapters) && chapter.isRecentEnough(minChapterDate) + } + return if (filtered.size == newChapters.size) this else copy(newChapters = filtered) + } + + private fun MangaChapter.isAfterReadPosition(chapters: List, readChapters: Int): Boolean { + if (readChapters <= 0) { + return true } + val index = chapters.indexOfFirst { it.id == id } + return index < 0 || index >= readChapters + } + + private fun MangaChapter.isRecentEnough(minChapterDate: Long): Boolean { + return uploadDate == 0L || uploadDate >= minChapterDate + } + + private fun MangaHistory?.getReadChaptersCount(chapters: List): Int { + if (this == null) { + return 0 + } + val index = chapters.indexOfFirst { it.id == chapterId } + return when { + index >= 0 -> index + 1 + else -> estimatedReadChaptersCount().coerceAtMost(chapters.size) + } + } + + private fun MangaHistory.estimatedReadChaptersCount(): Int { + if (chaptersCount <= 0 || !ReadingProgress.isValid(percent)) { + return 0 + } + return if (ReadingProgress.isCompleted(percent)) { + chaptersCount + } else { + (percent * chaptersCount).toInt().coerceIn(0, chaptersCount) + } + } + + private companion object { + + const val SIMILAR_HISTORY_LIMIT = 8 + const val MAX_STALE_CHAPTER_DAYS = 90L } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/GetTracksUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/GetTracksUseCase.kt index 7b220a243a..d688be4653 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/GetTracksUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/GetTracksUseCase.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.tracker.domain import io.github.landwarderer.futon.tracker.domain.model.MangaTracking +import java.util.concurrent.TimeUnit import javax.inject.Inject class GetTracksUseCase @Inject constructor( @@ -9,6 +10,22 @@ class GetTracksUseCase @Inject constructor( suspend operator fun invoke(limit: Int): List { repository.updateTracks() - return repository.getTracks(offset = 0, limit = limit) + val now = System.currentTimeMillis() + return repository.getTracks( + offset = 0, + limit = limit, + minActivityTime = now - TimeUnit.DAYS.toMillis(MAX_INACTIVE_DAYS), + // Inactive manga must still be re-checked occasionally: last_chapter_date only + // advances when a check runs, so without this floor a manga that crosses the + // inactivity window (hiatus, or a source without upload dates) would be + // excluded from checking forever. + staleCheckTime = now - TimeUnit.DAYS.toMillis(STALE_CHECK_DAYS), + ) + } + + private companion object { + + const val MAX_INACTIVE_DAYS = 90L + const val STALE_CHECK_DAYS = 7L } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackerUnstuckMigrationUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackerUnstuckMigrationUseCase.kt new file mode 100644 index 0000000000..ef885b6acc --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackerUnstuckMigrationUseCase.kt @@ -0,0 +1,199 @@ +package io.github.landwarderer.futon.tracker.domain + +import coil3.request.CachePolicy +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.db.entity.toManga +import io.github.landwarderer.futon.core.model.isLocal +import io.github.landwarderer.futon.core.os.NetworkState +import io.github.landwarderer.futon.core.parser.CachingMangaRepository +import io.github.landwarderer.futon.core.parser.MangaRepository +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import io.github.landwarderer.futon.core.util.ext.toInstantOrNull +import io.github.landwarderer.futon.local.data.LocalMangaRepository +import io.github.landwarderer.futon.tracker.domain.model.MangaTracking +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.findById +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +private const val MAX_PARALLELISM = 4 + +/** + * One-shot migration that recovers tracks bugged by the previous reader-side + * "swallow new chapters" defect of [CheckNewChaptersUseCase]. For each track whose + * last_chapter_id was silently advanced past chapters the user never actually opened, + * this credits those chapters as new so they reappear in the Updates tab. + * + * Heuristic: if the user's history.chapterId for a manga differs from the + * track's last_chapter_id, the gap between them is credited as new. Tracks + * where history matches last_chapter_id (user genuinely caught up) are + * skipped, as are mangas without history or whose history chapter no longer + * exists in the fresh chapter list. + */ +@Singleton +class TrackerUnstuckMigrationUseCase @Inject constructor( + private val db: MangaDatabase, + private val trackingRepository: TrackingRepository, + private val mangaRepositoryFactory: MangaRepository.Factory, + private val localMangaRepository: LocalMangaRepository, + private val networkState: NetworkState, + private val settings: AppSettings, +) { + + suspend fun runIfNeeded() { + // Offline -> defer all passes; setting flags now would burn the one-shots. + if (!networkState.value) return + if (!settings.isTrackerUnstuckMigrationDone) { + runCatchingCancellable { + runImpl() + }.onSuccess { + settings.isTrackerUnstuckMigrationDone = true + }.onFailure { e -> + e.printStackTraceDebug("TrackerUnstuckMigrationUseCase::runImpl") + } + } + if (!settings.isTrackerProgressRefreshDone) { + runCatchingCancellable { + refreshProgressImpl() + }.onSuccess { + settings.isTrackerProgressRefreshDone = true + }.onFailure { e -> + e.printStackTraceDebug("TrackerUnstuckMigrationUseCase::refreshProgressImpl") + } + } + } + + /** + * Second pass: refreshes history.percent for every track that + * already has chapters_new > 0, so those mangas no longer render as "completed" even though + * new chapters exist. + */ + private suspend fun refreshProgressImpl() = coroutineScope { + val historyDao = db.getHistoryDao() + val candidates = findAllTracks().filter { it.track.newChapters > 0 } + val semaphore = Semaphore(MAX_PARALLELISM) + for (entry in candidates) { + val history = historyDao.find(entry.track.mangaId) ?: continue + launch { + semaphore.withPermit { + runCatchingCancellable { + refreshOne( + manga = entry.manga.toManga(emptySet(), null), + historyChapterId = history.chapterId, + ) + }.onFailure { it.printStackTraceDebug("TrackerUnstuckMigrationUseCase::refreshOne") } + } + } + } + } + + private suspend fun refreshOne(manga: Manga, historyChapterId: Long) { + val details = getFullManga(manga) ?: return + val historyChapter = details.chapters?.findById(historyChapterId) ?: return + val chapters = details.getChapters(historyChapter.branch) + val historyIndex = chapters.indexOfFirst { it.id == historyChapterId } + if (historyIndex < 0) return + fixHistoryProgress(manga.id, historyIndex, chapters.size) + } + + private suspend fun runImpl() = coroutineScope { + val historyDao = db.getHistoryDao() + val candidates = findAllTracks().filter { it.track.newChapters == 0 && it.track.lastChapterId != 0L } + val semaphore = Semaphore(MAX_PARALLELISM) + for (entry in candidates) { + val history = historyDao.find(entry.track.mangaId) ?: continue + if (history.chapterId == entry.track.lastChapterId) continue + launch { + semaphore.withPermit { + runCatchingCancellable { + processOne( + manga = entry.manga.toManga(emptySet(), null), + trackLastChapterId = entry.track.lastChapterId, + historyChapterId = history.chapterId, + ) + }.onFailure { it.printStackTraceDebug("TrackerUnstuckMigrationUseCase::processOne") } + } + } + } + } + + private suspend fun processOne(manga: Manga, trackLastChapterId: Long, historyChapterId: Long) { + val details = getFullManga(manga) ?: return + val historyChapter = details.chapters?.findById(historyChapterId) + val trackChapter = details.chapters?.findById(trackLastChapterId) + if (historyChapter == null && trackChapter == null) return + val branch = historyChapter?.branch ?: trackChapter?.branch + val chapters = details.getChapters(branch) + if (chapters.isEmpty()) return + val historyIndex = chapters.indexOfFirst { it.id == historyChapterId } + if (historyIndex < 0) return + val creditable = chapters.lastIndex - historyIndex + if (creditable <= 0) return + val lastChapter = chapters.last() + trackingRepository.mergeWith( + MangaTracking( + manga = details, + lastChapterId = lastChapter.id, + lastCheck = Instant.now(), + lastChapterDate = lastChapter.uploadDate.toInstantOrNull(), + newChapters = creditable, + ), + ) + fixHistoryProgress(manga.id, historyIndex, chapters.size) + } + + /** + * Refreshes display-only history fields against the new chapter count so that mangas freshly + * credited as having new chapters no longer render as "100% complete". Read position + * (chapter_id, page, scroll) is preserved. + */ + private suspend fun fixHistoryProgress(mangaId: Long, historyIndex: Int, totalChapters: Int) { + if (totalChapters <= 0) return + val historyDao = db.getHistoryDao() + val history = historyDao.find(mangaId) ?: return + val wasCompleted = history.percent >= 0.99f + val newPercent = if (wasCompleted) { + (historyIndex + 1).toFloat() / totalChapters + } else { + history.percent + } + // Keep chaptersCount: smart resume reads it as the chapter count at the reader's last save + if (history.percent == newPercent) return + historyDao.update(history.copy(percent = newPercent)) + } + + private suspend fun findAllTracks() = db.getTracksDao().findAll( + offset = 0, + limit = Int.MAX_VALUE, + minActivityTime = Long.MIN_VALUE, + staleCheckTime = 0L, + ) + + private suspend fun getFullManga(manga: Manga): Manga? = runCatchingCancellable { + when { + manga.isLocal -> { + val remote = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null + fetchDetails(remote) + } + + manga.chapters.isNullOrEmpty() -> fetchDetails(manga) + else -> manga + } + }.getOrNull() + + private suspend fun fetchDetails(manga: Manga): Manga { + val repo = mangaRepositoryFactory.create(manga.source) + return if (repo is CachingMangaRepository) { + repo.getDetails(manga, CachePolicy.WRITE_ONLY) + } else { + repo.getDetails(manga) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt index 5571f8f2c6..9138efedc0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt @@ -27,11 +27,13 @@ import io.github.landwarderer.futon.tracker.data.toTrackingLogItem import io.github.landwarderer.futon.tracker.domain.model.MangaTracking import io.github.landwarderer.futon.tracker.domain.model.MangaUpdates import io.github.landwarderer.futon.tracker.domain.model.TrackingLogItem +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject private const val NO_ID = 0L private const val MAX_LOG_SIZE = 120 +private const val MAX_STALE_UPDATE_DAYS = 90L @Reusable class TrackingRepository @Inject constructor( @@ -76,21 +78,41 @@ class TrackingRepository @Inject constructor( .onStart { gcIfNotCalled() } } - suspend fun getTracks(offset: Int, limit: Int): List { - return db.getTracksDao().findAll(offset = offset, limit = limit) - .filter { track -> - val manga = track.manga.toManga(emptySet(), null) - !isUpdateCheckingDisabled(manga) + suspend fun getTracks(offset: Int, limit: Int, minActivityTime: Long, staleCheckTime: Long): List { + // Tracks from sources with update checking disabled never get their last_check_time + // advanced, so they permanently sort to the front of the batch. Filtering them after + // the SQL LIMIT would let them occupy batch slots forever, starving the rest of the + // queue - page through until the requested amount of checkable tracks is collected. + val result = ArrayList(if (limit == Int.MAX_VALUE) 16 else limit) + var currentOffset = offset + while (result.size < limit) { + val window = db.getTracksDao().findAll( + offset = currentOffset, + limit = limit - result.size, + minActivityTime = minActivityTime, + staleCheckTime = staleCheckTime, + ) + if (window.isEmpty()) { + break } - .map { - MangaTracking( - manga = it.manga.toManga(emptySet(), null), - lastChapterId = it.track.lastChapterId, - lastCheck = it.track.lastCheckTime.toInstantOrNull(), - lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), - newChapters = it.track.newChapters, + currentOffset += window.size + for (item in window) { + val manga = item.manga.toManga(emptySet(), null) + if (isUpdateCheckingDisabled(manga)) { + continue + } + result.add( + MangaTracking( + manga = manga, + lastChapterId = item.track.lastChapterId, + lastCheck = item.track.lastCheckTime.toInstantOrNull(), + lastChapterDate = item.track.lastChapterDate.toInstantOrNull(), + newChapters = item.track.newChapters, + ), ) } + } + return result } private fun isUpdateCheckingDisabled(manga: Manga): Boolean { @@ -146,6 +168,7 @@ class TrackingRepository @Inject constructor( suspend fun gc() = db.withTransaction { db.getTracksDao().gc() + db.getTracksDao().clearStaleCounters(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(MAX_STALE_UPDATE_DAYS)) db.getTrackLogsDao().run { gc() trim(MAX_LOG_SIZE) @@ -207,11 +230,15 @@ class TrackingRepository @Inject constructor( dao.gc() val ids = dao.findAllIds().toMutableSet() val size = ids.size + // A manga can be tracked by both history and favourites: once the history pass has + // handled an id, the favourites pass must not touch it - recreating the row would + // wipe its baseline (lastChapterId), silently resetting the track on every run. + val handled = HashSet(size) // history if (AppSettings.TRACK_HISTORY in settings.trackSources) { val historyIds = db.getHistoryDao().findAllIds() for (mangaId in historyIds) { - if (!ids.remove(mangaId)) { + if (handled.add(mangaId) && !ids.remove(mangaId)) { dao.upsert(TrackEntity.create(mangaId)) } } @@ -220,7 +247,7 @@ class TrackingRepository @Inject constructor( if (AppSettings.TRACK_FAVOURITES in settings.trackSources) { val favoritesIds = db.getFavouritesDao().findIdsWithTrack() for (mangaId in favoritesIds) { - if (!ids.remove(mangaId)) { + if (handled.add(mangaId) && !ids.remove(mangaId)) { dao.upsert(TrackEntity.create(mangaId)) } } @@ -248,15 +275,24 @@ class TrackingRepository @Inject constructor( lastError = updates.error?.toString(), ) - is MangaUpdates.Success -> TrackEntity( - mangaId = mangaId, - lastChapterId = updates.manga.getChapters(updates.branch).lastOrNull()?.id ?: NO_ID, - newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, - lastCheckTime = System.currentTimeMillis(), - lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, - lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, - lastError = null, - ) + is MangaUpdates.Success -> { + val chapters = updates.manga.getChapters(updates.branch) + TrackEntity( + mangaId = mangaId, + lastChapterId = chapters.lastOrNull()?.id ?: NO_ID, + // Cap at the total chapter count: the unread counter can never exceed how many + // chapters exist, even if a transient detection glitch tries to inflate it. + newChapters = if (updates.isValid) { + (newChapters + updates.newChapters.size).coerceIn(0, chapters.size) + } else { + 0 + }, + lastCheckTime = System.currentTimeMillis(), + lastChapterDate = updates.lastChapterDate().ifZero { lastChapterDate }, + lastResult = if (updates.isNotEmpty()) TrackEntity.RESULT_HAS_UPDATE else TrackEntity.RESULT_NO_UPDATE, + lastError = null, + ) + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index 18a0b4b2c7..045e96aff5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -90,9 +90,13 @@ class TrackWorker @AssistedInject constructor( override suspend fun doWork(): Result { notificationHelper.updateChannels() - val isForeground = trySetForeground() + trySetForeground() + // Full run when manually triggered (TAG_ONESHOT), regardless of whether the + // foreground service promotion succeeded - Android may refuse the promotion + // on certain OEMs, but the user explicitly asked for an immediate full check. + val isFullRun = TAG_ONESHOT in tags return try { - doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags) + doWorkImpl(isFullRun = isFullRun) } catch (e: CancellationException) { throw e } catch (e: Throwable) { From 1c70098b071fad8d9bea6e0da42b03c5f7982acf Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 10/27] feat(feed): swipe to remove a feed item, with undo Ported from Kotatsu-Redo 9.8.3 (77d993d92). --- .../futon/core/db/dao/TrackLogsDao.kt | 6 +++ .../tracker/domain/TrackingRepository.kt | 15 ++++++ .../futon/tracker/ui/feed/FeedFragment.kt | 4 ++ .../futon/tracker/ui/feed/FeedViewModel.kt | 11 +++++ .../ui/feed/adapter/FeedItemTouchCallback.kt | 47 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 84 insertions(+) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/adapter/FeedItemTouchCallback.kt diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TrackLogsDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TrackLogsDao.kt index 078da3dcea..5ceb2e227a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TrackLogsDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/TrackLogsDao.kt @@ -36,6 +36,12 @@ abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback { @Query("UPDATE track_logs SET unread = 0 WHERE id = :id") abstract suspend fun markAsRead(id: Long) + @Query("SELECT * FROM track_logs WHERE id = :id") + abstract suspend fun find(id: Long): TrackLogEntity? + + @Query("DELETE FROM track_logs WHERE id = :id") + abstract suspend fun delete(id: Long) + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun insert(entity: TrackLogEntity): Long diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt index 9138efedc0..70ac29566d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt @@ -166,6 +166,21 @@ class TrackingRepository @Inject constructor( suspend fun markAsRead(trackLogId: Long) = db.getTrackLogsDao().markAsRead(trackLogId) + /** + * Deletes a single feed entry and returns it, so the caller can offer to put it back. + * Returns null if it was already gone. + */ + suspend fun removeLog(trackLogId: Long): TrackLogEntity? = db.withTransaction { + val dao = db.getTrackLogsDao() + dao.find(trackLogId)?.also { dao.delete(trackLogId) } + } + + /** Reinserts an entry removed by [removeLog]; the entity carries its original id, so it goes back + * in the same place in the feed rather than to the top. */ + suspend fun restoreLog(entity: TrackLogEntity) { + db.getTrackLogsDao().insert(entity) + } + suspend fun gc() = db.withTransaction { db.getTracksDao().gc() db.getTracksDao().clearStaleCounters(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(MAX_STALE_UPDATE_DAYS)) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedFragment.kt index c1a3e3aed4..f72d713e7f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedFragment.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -36,6 +37,7 @@ import io.github.landwarderer.futon.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import io.github.landwarderer.futon.tracker.ui.feed.adapter.FeedAdapter +import io.github.landwarderer.futon.tracker.ui.feed.adapter.FeedItemTouchCallback import javax.inject.Inject @AndroidEntryPoint @@ -76,6 +78,8 @@ class FeedFragment : addItemDecoration(TypedListSpacingDecoration(context, true)) RecyclerScrollKeeper(this).attach() } + ItemTouchHelper(FeedItemTouchCallback(viewModel::removeItem)) + .attachToRecyclerView(binding.recyclerView) binding.swipeRefreshLayout.setOnRefreshListener(this) addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt index 96020ebe6b..6976bc4911 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt @@ -125,6 +125,17 @@ class FeedViewModel @Inject constructor( settings.isFeedHeaderVisible = value } + fun removeItem(item: FeedItem) { + launchJob(Dispatchers.Default) { + val removed = repository.removeLog(item.id) ?: return@launchJob + onActionDone.call( + ReversibleAction(R.string.update_removed) { + repository.restoreLog(removed) + }, + ) + } + } + fun onItemClick(item: FeedItem) { launchJob(Dispatchers.IO, CoroutineStart.ATOMIC) { repository.markAsRead(item.id) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/adapter/FeedItemTouchCallback.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/adapter/FeedItemTouchCallback.kt new file mode 100644 index 0000000000..10428530de --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/adapter/FeedItemTouchCallback.kt @@ -0,0 +1,47 @@ +package io.github.landwarderer.futon.tracker.ui.feed.adapter + +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import io.github.landwarderer.futon.core.util.ext.getItem +import io.github.landwarderer.futon.list.ui.adapter.ListItemType +import io.github.landwarderer.futon.tracker.ui.feed.model.FeedItem + +/** + * Swipe a feed entry away to delete it. Only the update rows are swipeable: the date headers, the + * updated manga strip, the quick filter and the state placeholders are not entries and have nothing + * to delete. + */ +class FeedItemTouchCallback( + private val listener: FeedItemListener, +) : ItemTouchHelper.Callback() { + + private val movementFlags = makeMovementFlags( + 0, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, + ) + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int = if (viewHolder.itemViewType == ListItemType.FEED.ordinal) { + movementFlags + } else { + 0 + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val item = viewHolder.getItem(FeedItem::class.java) ?: return + listener.onRemoveFeedItem(item) + } + + fun interface FeedItemListener { + + fun onRemoveFeedItem(item: FeedItem) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c03fcff28..9432c5bd96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1045,4 +1045,5 @@ Learn how to use Futon, dual modes, and source extensions Use this to switch between manga and novels Got it + Update removed From c958745d7f7d127ba062c46397bba30737d0e8d7 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:28 +0000 Subject: [PATCH 11/27] feat(tracker): auto-download new chapters of recently read manga * feat(tracker): auto-download new chapters of recently read manga Adds a "Recently read manga" tracker download strategy: new chapters of manga read within the last 30 days are downloaded whether or not anything is saved locally. For both strategies, at most the 5 newest not yet downloaded chapters are taken per manga per run, and nothing is queued when the target storage has less than 2 GB free or the download storage quota is already reached (the worker would otherwise evict older chapters). Tracker downloads now go through the download queue instead of straight to the download worker, so the Wi-Fi, charging and off-peak settings apply to them as to smart downloads. Ported from Kotatsu-Redo 9.8.3 (77d993d92). * fix(tracker): keep downloading every new chapter of downloaded manga The 5-chapter cap from the recently-read strategy also hit the existing downloaded-manga strategy, and chapters over the cap were never offered again. Only cap the new recently-read strategy. --- .../core/prefs/TrackerDownloadStrategy.kt | 36 +++++- .../futon/tracker/work/TrackWorker.kt | 103 ++++++++++++++---- app/src/main/res/values/arrays.xml | 2 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/TrackerDownloadStrategy.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/TrackerDownloadStrategy.kt index 6ac27fcb5c..2dfbb46cb1 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/TrackerDownloadStrategy.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/TrackerDownloadStrategy.kt @@ -5,5 +5,39 @@ import androidx.annotation.Keep @Keep enum class TrackerDownloadStrategy { - DISABLED, DOWNLOADED; + /** Never download new chapters automatically. */ + DISABLED, + + /** + * Only for manga that already has chapters saved locally. + * + * Note this keys off downloaded files, not reading: a series read online never qualifies, and one + * whose read chapters were cleaned up silently stops qualifying. + */ + DOWNLOADED, + + /** + * For manga opened within [RECENT_READ_WINDOW_DAYS], whether or not anything is saved locally. + * + * "Have I read this lately" is the honest proxy for "do I still care about this", so a short break + * from a series keeps working while one abandoned months ago stops downloading on its own. + */ + RECENTLY_READ, + + ; + + companion object { + + /** How recently a manga must have been read to count for [RECENTLY_READ]. */ + const val RECENT_READ_WINDOW_DAYS = 30L + + /** + * Newest chapters downloaded per manga per run. A dormant series that suddenly backfills its + * archive would otherwise fill the device in a single tick. + */ + const val MAX_CHAPTERS_PER_RUN = 5 + + /** Auto-download is skipped below this much free space. */ + const val MIN_FREE_SPACE_BYTES = 2L * 1024 * 1024 * 1024 + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index 045e96aff5..2fd1435b90 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -36,6 +36,7 @@ import io.github.landwarderer.futon.core.exceptions.CloudFlareException import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.model.ids import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.TrackerDownloadStrategy import io.github.landwarderer.futon.core.prefs.TriStateOption @@ -44,9 +45,11 @@ import io.github.landwarderer.futon.core.util.ext.checkNotificationPermission import io.github.landwarderer.futon.core.util.ext.onEachIndexed import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.trySetForeground -import io.github.landwarderer.futon.download.ui.worker.DownloadTask -import io.github.landwarderer.futon.download.ui.worker.DownloadWorker +import io.github.landwarderer.futon.download.data.repository.DownloadQueueRepository +import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.local.data.LocalMangaRepository +import io.github.landwarderer.futon.local.domain.EnforceStorageQuotaUseCase +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import io.github.landwarderer.futon.settings.work.PeriodicWorkScheduler @@ -56,6 +59,7 @@ import io.github.landwarderer.futon.tracker.domain.model.MangaTracking import io.github.landwarderer.futon.tracker.domain.model.MangaUpdates import io.github.landwarderer.futon.tracker.work.TrackerNotificationHelper.NotificationInfo import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow @@ -65,7 +69,10 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -83,10 +90,14 @@ class TrackWorker @AssistedInject constructor( private val checkNewChaptersUseCase: CheckNewChaptersUseCase, private val workManager: WorkManager, private val localRepositoryLazy: Lazy, - private val downloadSchedulerLazy: Lazy, + private val mangaDataRepositoryLazy: Lazy, + private val downloadQueueRepositoryLazy: Lazy, + private val historyRepositoryLazy: Lazy, + private val enforceStorageQuotaUseCaseLazy: Lazy, ) : CoroutineWorker(context, workerParams) { private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } + private var isStorageQuotaReached: Boolean? = null override suspend fun doWork(): Result { notificationHelper.updateChannels() @@ -253,25 +264,79 @@ class TrackWorker @AssistedInject constructor( if (!mangaUpdates.isValid || mangaUpdates.newChapters.isEmpty()) { return } - when (settings.trackerDownloadStrategy) { - TrackerDownloadStrategy.DISABLED -> Unit - TrackerDownloadStrategy.DOWNLOADED -> { - val localManga = localRepositoryLazy.get().findSavedManga(mangaUpdates.manga) - if (localManga != null) { - val task = DownloadTask( - mangaId = mangaUpdates.manga.id, - isPaused = false, - isSilent = false, - chaptersIds = mangaUpdates.newChapters.ids().toLongArray(), - destination = null, - format = null, - allowMeteredNetwork = settings.allowDownloadOnMeteredNetwork != TriStateOption.DISABLED, - requiresCharging = false, - ) - downloadSchedulerLazy.get().schedule(setOf(mangaUpdates.manga to task)) + val strategy = settings.trackerDownloadStrategy + if (strategy == TrackerDownloadStrategy.DISABLED) { + return + } + val manga = mangaUpdates.manga + val localManga = localRepositoryLazy.get().findSavedManga(manga, withDetails = true) + val shouldDownload = when (strategy) { + TrackerDownloadStrategy.DISABLED -> false + TrackerDownloadStrategy.DOWNLOADED -> localManga != null + TrackerDownloadStrategy.RECENTLY_READ -> isReadRecently(manga) + } + if (!shouldDownload) { + return + } + val savedChapters = localManga?.manga?.chapters?.ids().orEmpty() + val chapters = mangaUpdates.newChapters + .filterNot { it.id in savedChapters } + .let { + // Only the newest few for recently read manga: a dormant series that backfills its archive + // would otherwise queue hundreds of chapters at once. Downloaded manga keep every new + // chapter, as before - the ones left out would never be offered again. + if (strategy == TrackerDownloadStrategy.RECENTLY_READ) { + it.takeLast(TrackerDownloadStrategy.MAX_CHAPTERS_PER_RUN) + } else { + it } } + if (chapters.isEmpty() || !hasFreeSpaceForDownload(manga) || isStorageQuotaReached()) { + return } + // the download worker resolves the chapters from the database, which must know the new ones + mangaDataRepositoryLazy.get().storeManga(manga, replaceExisting = true) + // Through the download queue, so the off-peak, charging and Wi-Fi rules apply + downloadQueueRepositoryLazy.get().addToQueue( + manga = manga, + chaptersIds = chapters.ids().toLongArray(), + wifiOnly = settings.allowDownloadOnMeteredNetwork == TriStateOption.DISABLED, + chargingOnly = settings.isDownloadOnlyOnChargingEnabled, + offPeakOnly = settings.isDownloadOffPeakEnabled, + isPaused = false, + ) + } + + private suspend fun isReadRecently(manga: Manga): Boolean { + val lastRead = historyRepositoryLazy.get().getOne(manga)?.updatedAt ?: return false + val threshold = Instant.now().minus(TrackerDownloadStrategy.RECENT_READ_WINDOW_DAYS, ChronoUnit.DAYS) + return lastRead.isAfter(threshold) + } + + /** + * Auto-download is unattended, so it must not be the thing that fills the device. + * Checks the volume this manga would actually be written to; a directory whose free space + * cannot be read (reported as zero) does not block the download. + */ + private suspend fun hasFreeSpaceForDownload(manga: Manga): Boolean = runCatchingCancellable { + val dir = localRepositoryLazy.get().getOutputDir(manga, null) ?: return@runCatchingCancellable false + val available = runInterruptible(Dispatchers.IO) { dir.usableSpace } + available == 0L || available >= TrackerDownloadStrategy.MIN_FREE_SPACE_BYTES + }.getOrDefault(true) + + /** + * Reaching the storage quota makes the download worker evict the oldest downloaded chapters, + * which an unattended download should not do: skip auto-downloads until there is room again. + * Computed once per run, as it walks the whole local storage. + */ + private suspend fun isStorageQuotaReached(): Boolean { + isStorageQuotaReached?.let { return it } + val usage = runCatchingCancellable { + enforceStorageQuotaUseCaseLazy.get().getUsage() + }.getOrNull() + val result = usage != null && usage.currentBytes >= usage.totalBytes + isStorageQuotaReached = result + return result } @Reusable diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 8c3fd47c4d..488ffc9673 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -110,9 +110,11 @@ @string/chapters_read @string/chapters_left + @string/never @string/manga_with_downloaded_chapters + @string/recently_read_manga @string/allow_always diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9432c5bd96..a76d28f8a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1046,4 +1046,5 @@ Use this to switch between manga and novels Got it Update removed + Recently read manga From 5b1ed3cd28611ebd3c40c7481e3319ae9d3f7716 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:51:36 +0000 Subject: [PATCH 12/27] perf(tracker): per-source limits, stop at blocked sources, save fixes * fix(tracker): refresh progress outside the save transaction, date the tracked branch saveUpdates recalculated reading progress inside its database transaction, and that can load the chapter pages from the network: a slow source kept the transaction open for the whole request. The track and its feed entry are now saved first and the progress refreshed after; a failed refresh no longer fails a check that was already saved. When no new chapter has an upload date, lastChapterDate() fell back to the last chapter across every branch instead of the tracked one. * perf(tracker): limit per-source checks and stop at blocked sources The tracker ran up to 6 checks at a time with no regard for the source, so one source could take every slot, and a Cloudflare-protected or rate-limited source failed once for each of its manga in the batch. - At most 2 checks run per source. The source permit is taken before the global one, so a busy source does not hold global slots while its own checks wait. - The first Cloudflare or rate-limit (429) failure of a source stops it for the rest of the run: its other manga are not requested, and are stamped as checked with that failure in one update, so a blocked source still cannot fill the following batches. - Captcha failures are stored as a new result, RESULT_CAPTCHA, so they can be found again once the captcha is solved. The tracker debug screen shows them like failures. - The captcha notification also finds a Cloudflare error wrapped in another exception. --- .../futon/core/util/ext/Throwable.kt | 7 +++ .../futon/tracker/data/TrackEntity.kt | 12 ++++- .../futon/tracker/data/TracksDao.kt | 4 ++ .../tracker/domain/TrackingRepository.kt | 43 +++++++++++++++-- .../tracker/domain/model/MangaUpdates.kt | 13 ++++- .../futon/tracker/ui/debug/TrackDebugAD.kt | 8 +++- .../futon/tracker/work/TrackWorker.kt | 48 +++++++++++++++---- 7 files changed, 119 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt index 20b2b7e36e..71fcd96398 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt @@ -21,6 +21,7 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.exceptions.BadBackupFormatException import io.github.landwarderer.futon.core.exceptions.CaughtException import io.github.landwarderer.futon.core.exceptions.CloudFlareBlockedException +import io.github.landwarderer.futon.core.exceptions.CloudFlareException import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.exceptions.EmptyHistoryException import io.github.landwarderer.futon.core.exceptions.EmptyMangaException @@ -159,6 +160,12 @@ fun Throwable.getDisplayIcon(): Int = when (this) { else -> R.drawable.ic_error_large } +/** Walks the exception cause chain and returns the first [CloudFlareException] found, or `null`. */ +fun Throwable.findCloudFlareException(): CloudFlareException? = + generateSequence(this) { it.cause?.takeIf { c -> c !== it } } + .filterIsInstance() + .firstOrNull() + fun Throwable.getCauseUrl(): String? = when (this) { is ParseException -> url is NotFoundException -> url diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TrackEntity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TrackEntity.kt index feb08f2f07..c24bd28d44 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TrackEntity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TrackEntity.kt @@ -30,7 +30,14 @@ class TrackEntity( @ColumnInfo(name = "last_error") val lastError: String?, ) { - @IntDef(RESULT_NONE, RESULT_HAS_UPDATE, RESULT_NO_UPDATE, RESULT_FAILED, RESULT_EXTERNAL_MODIFICATION) + @IntDef( + RESULT_NONE, + RESULT_HAS_UPDATE, + RESULT_NO_UPDATE, + RESULT_FAILED, + RESULT_EXTERNAL_MODIFICATION, + RESULT_CAPTCHA, + ) @Retention(AnnotationRetention.SOURCE) annotation class TrackerResult @@ -42,6 +49,9 @@ class TrackEntity( const val RESULT_FAILED = 3 const val RESULT_EXTERNAL_MODIFICATION = 4 + /** The source asked for a captcha: the track is checked again once the captcha is solved. */ + const val RESULT_CAPTCHA = 5 + fun create(mangaId: Long) = TrackEntity( mangaId = mangaId, lastChapterId = 0L, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt index 5946b01346..aeb76b8b55 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt @@ -100,6 +100,10 @@ abstract class TracksDao : MangaQueryBuilder.ConditionCallback { @Query("UPDATE tracks SET chapters_new = 0 WHERE chapters_new > 0 AND last_chapter_date > 0 AND last_chapter_date < :minChapterDate") abstract suspend fun clearStaleCounters(minChapterDate: Long) + /** Records a check that was skipped, keeping the tracking state (baseline and counter) as it is. */ + @Query("UPDATE tracks SET last_check_time = :time, last_result = :result, last_error = :error WHERE manga_id IN (:ids)") + abstract suspend fun markChecked(ids: Collection, time: Long, result: Int, error: String?) + @Query("DELETE FROM tracks WHERE manga_id = :mangaId") abstract suspend fun delete(mangaId: Long) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt index 70ac29566d..4e9761f768 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt @@ -15,12 +15,14 @@ import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.parser.ParserMangaRepository import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.mapItems +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.toInstantOrNull import io.github.landwarderer.futon.details.domain.ProgressUpdateUseCase import io.github.landwarderer.futon.list.domain.ListFilterOption import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.ifZero +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.tracker.data.TrackEntity import io.github.landwarderer.futon.tracker.data.TrackLogEntity import io.github.landwarderer.futon.tracker.data.toTrackingLogItem @@ -34,6 +36,7 @@ import javax.inject.Inject private const val NO_ID = 0L private const val MAX_LOG_SIZE = 120 private const val MAX_STALE_UPDATE_DAYS = 90L +private const val MAX_QUERY_IDS = 500 @Reusable class TrackingRepository @Inject constructor( @@ -191,11 +194,11 @@ class TrackingRepository @Inject constructor( } suspend fun saveUpdates(updates: MangaUpdates) { + val hasNewChapters = updates is MangaUpdates.Success && updates.isValid && updates.newChapters.isNotEmpty() db.withTransaction { val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) db.getTracksDao().upsert(track) - if (updates is MangaUpdates.Success && updates.isValid && updates.newChapters.isNotEmpty()) { - progressUpdateUseCase(updates.manga) + if (updates is MangaUpdates.Success && hasNewChapters) { val logEntity = TrackLogEntity( mangaId = updates.manga.id, chapters = updates.newChapters.joinToString("\n") { x -> x.name }, @@ -205,6 +208,34 @@ class TrackingRepository @Inject constructor( db.getTrackLogsDao().insert(logEntity) } } + if (hasNewChapters) { + // Outside the transaction: the progress refresh may load the chapter pages from the network. + // It only refreshes the displayed progress, so a failure must not fail the saved check. + runCatchingCancellable { + progressUpdateUseCase(updates.manga) + }.onFailure { e -> + e.printStackTraceDebug("TrackingRepository::saveUpdates") + } + } + } + + /** + * Records tracks that were not checked because their source failed with [failure] earlier in the same run. + * They are stamped as checked, so a blocked source cannot fill every following batch. + */ + suspend fun saveSkipped(mangaIds: Collection, failure: MangaUpdates.Failure) { + if (mangaIds.isEmpty()) { + return + } + val now = System.currentTimeMillis() + val result = failure.toResultCode() + val error = failure.error?.toString() + db.withTransaction { + // stay below the SQLite bound parameters limit + for (chunk in mangaIds.chunked(MAX_QUERY_IDS)) { + db.getTracksDao().markChecked(chunk, now, result, error) + } + } } suspend fun clearUpdates(ids: Collection) { @@ -286,7 +317,7 @@ class TrackingRepository @Inject constructor( newChapters = newChapters, lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, - lastResult = TrackEntity.RESULT_FAILED, + lastResult = updates.toResultCode(), lastError = updates.error?.toString(), ) @@ -311,6 +342,12 @@ class TrackingRepository @Inject constructor( } } + private fun MangaUpdates.Failure.toResultCode(): Int = if (isCaptchaRequired()) { + TrackEntity.RESULT_CAPTCHA + } else { + TrackEntity.RESULT_FAILED + } + private suspend fun gcIfNotCalled() { if (isGcCalled.compareAndSet(false, true)) { gc() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/model/MangaUpdates.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/model/MangaUpdates.kt index 55feac89d8..3151b9ec29 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/model/MangaUpdates.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/model/MangaUpdates.kt @@ -1,5 +1,7 @@ package io.github.landwarderer.futon.tracker.domain.model +import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -21,7 +23,7 @@ sealed interface MangaUpdates { fun lastChapterDate(): Long { val lastChapter = newChapters.lastOrNull() return lastChapter?.uploadDate?.ifZero { System.currentTimeMillis() } - ?: (manga.chapters?.lastOrNull()?.uploadDate ?: 0L) + ?: (manga.getChapters(branch).lastOrNull()?.uploadDate ?: 0L) } } @@ -31,5 +33,14 @@ sealed interface MangaUpdates { ) : MangaUpdates { fun shouldRetry() = error is TooManyRequestExceptions + + /** The source wants a captcha solved; solving it unblocks the source. */ + fun isCaptchaRequired() = error?.findCloudFlareException() is CloudFlareProtectedException + + /** + * The source rejects every request for now (Cloudflare or rate limiting), so the rest of its manga + * would fail the same way. + */ + fun isSourceBlocked() = shouldRetry() || error?.findCloudFlareException() != null } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/debug/TrackDebugAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/debug/TrackDebugAD.kt index 640f8b5ae4..0c89838330 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/debug/TrackDebugAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/debug/TrackDebugAD.kt @@ -41,11 +41,15 @@ fun trackDebugAD( ) } ?: getString(R.string.never), ) - if (item.lastResult == TrackEntity.RESULT_FAILED) { + if (item.lastResult == TrackEntity.RESULT_FAILED || item.lastResult == TrackEntity.RESULT_CAPTCHA) { append(" - ") bold { color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) { - append(item.lastError ?: getString(R.string.error)) + append( + item.lastError ?: getString( + if (item.lastResult == TrackEntity.RESULT_CAPTCHA) R.string.captcha_required else R.string.error, + ), + ) } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index 2fd1435b90..23770ca6f4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -32,7 +32,6 @@ import dagger.assisted.AssistedInject import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.db.MangaDatabase -import io.github.landwarderer.futon.core.exceptions.CloudFlareException import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.model.ids import io.github.landwarderer.futon.core.nav.AppRouter @@ -42,6 +41,7 @@ import io.github.landwarderer.futon.core.prefs.TrackerDownloadStrategy import io.github.landwarderer.futon.core.prefs.TriStateOption import io.github.landwarderer.futon.core.util.ext.awaitUniqueWorkInfoByName import io.github.landwarderer.futon.core.util.ext.checkNotificationPermission +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException import io.github.landwarderer.futon.core.util.ext.onEachIndexed import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.trySetForeground @@ -50,11 +50,13 @@ import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.local.data.LocalMangaRepository import io.github.landwarderer.futon.local.domain.EnforceStorageQuotaUseCase import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import io.github.landwarderer.futon.settings.work.PeriodicWorkScheduler import io.github.landwarderer.futon.tracker.domain.CheckNewChaptersUseCase import io.github.landwarderer.futon.tracker.domain.GetTracksUseCase +import io.github.landwarderer.futon.tracker.domain.TrackingRepository import io.github.landwarderer.futon.tracker.domain.model.MangaTracking import io.github.landwarderer.futon.tracker.domain.model.MangaUpdates import io.github.landwarderer.futon.tracker.work.TrackerNotificationHelper.NotificationInfo @@ -63,8 +65,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -73,6 +77,8 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -88,6 +94,7 @@ class TrackWorker @AssistedInject constructor( private val settings: AppSettings, private val getTracksUseCase: GetTracksUseCase, private val checkNewChaptersUseCase: CheckNewChaptersUseCase, + private val trackingRepository: TrackingRepository, private val workManager: WorkManager, private val localRepositoryLazy: Lazy, private val mangaDataRepositoryLazy: Lazy, @@ -143,11 +150,23 @@ class TrackWorker @AssistedInject constructor( @CheckResult private suspend fun checkUpdatesAsync(tracks: List): List { val semaphore = Semaphore(MAX_PARALLELISM) - return channelFlow { + val sourceSemaphores = HashMap() + // Sources that failed in a way every other request of this run would repeat, with that failure + val blockedSources = ConcurrentHashMap() + val skipped = ConcurrentHashMap>() + val notifications = channelFlow { for (track in tracks) { + val source = track.manga.source + val sourceSemaphore = sourceSemaphores.getOrPut(source) { Semaphore(MAX_PER_SOURCE_PARALLELISM) } launch { - semaphore.withPermit { - send( + // The source permit comes first: a busy source must not hold the global slots while it waits + sourceSemaphore.withPermit { + if (blockedSources.containsKey(source)) { + skipped.getOrPut(source) { Collections.synchronizedList(ArrayList()) }.add(track.manga.id) + send(null) + return@withPermit + } + val updates = semaphore.withPermit { runCatchingCancellable { checkNewChaptersUseCase.invoke(track) }.getOrElse { error -> @@ -155,19 +174,24 @@ class TrackWorker @AssistedInject constructor( manga = track.manga, error = error, ) - }, - ) + } + } + if (updates is MangaUpdates.Failure && updates.isSourceBlocked()) { + blockedSources.putIfAbsent(source, updates) + } + send(updates) } } } - }.onEachIndexed { index, it -> + }.onEachIndexed { index, _ -> if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) { notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) } + }.filterNotNull().onEach { when (it) { is MangaUpdates.Failure -> { - val e = it.error - if (e is CloudFlareException) { + val e = it.error?.findCloudFlareException() + if (e != null) { captchaHandler.handle(e) } } @@ -187,6 +211,11 @@ class TrackWorker @AssistedInject constructor( } } }.toList() + for ((source, ids) in skipped) { + val failure = blockedSources[source] ?: continue + trackingRepository.saveSkipped(ids, failure) + } + return notifications } override suspend fun getForegroundInfo(): ForegroundInfo { @@ -409,6 +438,7 @@ class TrackWorker @AssistedInject constructor( const val TAG = "tracking" const val TAG_ONESHOT = "tracking_oneshot" const val MAX_PARALLELISM = 6 + const val MAX_PER_SOURCE_PARALLELISM = 2 val BATCH_SIZE = if (BuildConfig.DEBUG) 20 else 46 const val SETTINGS_ACTION_CODE = 5 } From 9e641d0a625946cdc97263ec258e4be5dcec47d6 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:51:36 +0000 Subject: [PATCH 13/27] feat(tracker): re-check captcha-blocked manga after a solve Manga whose check stopped at a Cloudflare captcha used to wait for their next turn in the queue, up to a full tracking cycle, even after the captcha was solved. CaptchaHandler now reports each solve, whether automatic or from the captcha notification, through solvedSources. Swiping the notification away is not a solve. WorkScheduleManager listens and queues one re-check per source: a one-time TrackWorker run that checks only the tracks of that source stored as RESULT_CAPTCHA. A re-check already waiting or running for the source is kept. It cannot loop. A re-check that meets the captcha again only shows the captcha notification, and only another solve queues a new one. On by default: "Re-check after solving a CAPTCHA" in the tracker settings. --- .../core/exceptions/resolve/CaptchaHandler.kt | 10 ++++++ .../futon/core/prefs/AppSettings.kt | 4 +++ .../settings/work/WorkScheduleManager.kt | 9 +++++ .../futon/tracker/data/TracksDao.kt | 12 +++++++ .../tracker/domain/TrackingRepository.kt | 13 +++++++ .../futon/tracker/work/TrackWorker.kt | 36 ++++++++++++++++--- app/src/main/res/values/strings.xml | 2 ++ app/src/main/res/xml/pref_tracker.xml | 7 ++++ 8 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt index 12f8953854..2fdde391b4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt @@ -54,6 +54,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -72,12 +75,18 @@ class CaptchaHandler @Inject constructor( private val exceptionMap = MutableScatterMap() private val mutex = Mutex() + private val solvedSourcesFlow = MutableSharedFlow(extraBufferCapacity = SOLVED_BUFFER_CAPACITY) + + /** Sources whose captcha was just solved, by the user or automatically. */ + val solvedSources: SharedFlow = solvedSourcesFlow.asSharedFlow() @CheckResult suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true) + /** Called once the captcha of [source] has been solved. */ suspend fun discard(source: MangaSource) { handleException(source, null, true) + solvedSourcesFlow.emit(source) } override fun onError(request: ImageRequest, result: ErrorResult) { @@ -287,6 +296,7 @@ class CaptchaHandler @Inject constructor( private const val GROUP_NOTIFICATION_ID = 34 private const val SETTINGS_ACTION_CODE = 3 private const val ACTION_DISCARD = "io.github.landwarderer.futon.CAPTCHA_DISCARD" + private const val SOLVED_BUFFER_CAPACITY = 8 private const val RESOLVE_TIMEOUT = 20_000L } } 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 9b11f12ea0..cd1a875c89 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 @@ -255,6 +255,9 @@ class AppSettings internal constructor( val trackerDownloadStrategy: TrackerDownloadStrategy get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED) + val isTrackerCaptchaRecheckEnabled: Boolean + get() = prefs.getBoolean(KEY_TRACKER_CAPTCHA_RECHECK, true) + var isTrackerUnstuckMigrationDone: Boolean get() = prefs.getBoolean(KEY_TRACKER_UNSTUCK_MIGRATION, false) set(value) = prefs.edit { putBoolean(KEY_TRACKER_UNSTUCK_MIGRATION, value) } @@ -872,6 +875,7 @@ class AppSettings internal constructor( const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw" const val KEY_TRACKER_DOWNLOAD = "tracker_download" + const val KEY_TRACKER_CAPTCHA_RECHECK = "tracker_captcha_recheck" const val KEY_TRACKER_UNSTUCK_MIGRATION = "tracker_unstuck_migration" const val KEY_TRACKER_PROGRESS_REFRESH = "tracker_progress_refresh" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt index 5122fd7b1e..55885288e7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/work/WorkScheduleManager.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.settings.work import android.content.SharedPreferences +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.util.ext.processLifecycleScope import io.github.landwarderer.futon.download.ui.worker.DownloadSchedulerWorker @@ -21,6 +22,7 @@ class WorkScheduleManager @Inject constructor( private val trackerScheduler: TrackWorker.Scheduler, private val downloadScheduler: DownloadWorker.Scheduler, private val trackerUnstuckMigrationProvider: Provider, + private val captchaHandler: CaptchaHandler, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -60,6 +62,13 @@ class WorkScheduleManager @Inject constructor( // Runs at most once per install (gated by settings flags). trackerUnstuckMigrationProvider.get().runIfNeeded() } + processLifecycleScope.launch(Dispatchers.Default) { + captchaHandler.solvedSources.collect { source -> + if (settings.isTrackerEnabled && settings.isTrackerCaptchaRecheckEnabled) { + trackerScheduler.recheckSource(source) + } + } + } } private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt index aeb76b8b55..4d0cac0d2e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/data/TracksDao.kt @@ -43,6 +43,18 @@ abstract class TracksDao : MangaQueryBuilder.ConditionCallback { ) abstract suspend fun findAll(offset: Int, limit: Int, minActivityTime: Long, staleCheckTime: Long): List + /** Tracks of [source] whose last check stopped at a captcha. */ + @Transaction + @Query( + """ + SELECT tracks.* FROM tracks + INNER JOIN manga ON manga.manga_id = tracks.manga_id + WHERE manga.source = :source AND tracks.last_result = ${TrackEntity.RESULT_CAPTCHA} + ORDER BY tracks.last_check_time ASC + """, + ) + abstract suspend fun findCaptchaBlocked(source: String): List + @Transaction @Query("SELECT * FROM tracks ORDER BY last_check_time DESC") abstract fun observeAll(): Flow> diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt index 4e9761f768..80e4225b44 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/domain/TrackingRepository.kt @@ -21,6 +21,7 @@ import io.github.landwarderer.futon.details.domain.ProgressUpdateUseCase import io.github.landwarderer.futon.list.domain.ListFilterOption import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.tracker.data.TrackEntity @@ -118,6 +119,18 @@ class TrackingRepository @Inject constructor( return result } + suspend fun getCaptchaBlockedTracks(source: MangaSource): List { + return db.getTracksDao().findCaptchaBlocked(source.name).map { item -> + MangaTracking( + manga = item.manga.toManga(emptySet(), null), + lastChapterId = item.track.lastChapterId, + lastCheck = item.track.lastCheckTime.toInstantOrNull(), + lastChapterDate = item.track.lastChapterDate.toInstantOrNull(), + newChapters = item.track.newChapters, + ) + } + } + private fun isUpdateCheckingDisabled(manga: Manga): Boolean { val repository = mangaRepositoryFactory.create(manga.source) if (repository !is ParserMangaRepository) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index 23770ca6f4..b206cb1aee 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -14,6 +14,7 @@ import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType @@ -25,6 +26,7 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.WorkerParameters import androidx.work.await +import androidx.work.workDataOf import dagger.Lazy import dagger.Reusable import dagger.assisted.Assisted @@ -32,6 +34,7 @@ import dagger.assisted.AssistedInject import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.model.MangaSource import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.model.ids import io.github.landwarderer.futon.core.nav.AppRouter @@ -113,8 +116,9 @@ class TrackWorker @AssistedInject constructor( // foreground service promotion succeeded - Android may refuse the promotion // on certain OEMs, but the user explicitly asked for an immediate full check. val isFullRun = TAG_ONESHOT in tags + val recheckSource = inputData.getString(KEY_SOURCE)?.let { MangaSource(it) } return try { - doWorkImpl(isFullRun = isFullRun) + doWorkImpl(isFullRun = isFullRun, recheckSource = recheckSource) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -127,11 +131,16 @@ class TrackWorker @AssistedInject constructor( } } - private suspend fun doWorkImpl(isFullRun: Boolean): Result { + private suspend fun doWorkImpl(isFullRun: Boolean, recheckSource: MangaSource?): Result { if (!settings.isTrackerEnabled) { return Result.success() } - val tracks = getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE) + val tracks = if (recheckSource != null) { + // Right after a captcha was solved: only the manga of that source which stopped at the captcha + trackingRepository.getCaptchaBlockedTracks(recheckSource) + } else { + getTracksUseCase(if (isFullRun) Int.MAX_VALUE else BATCH_SIZE) + } if (tracks.isEmpty()) { return Result.success() } @@ -418,8 +427,25 @@ class TrackWorker @AssistedInject constructor( workManager.enqueue(request) } + /** + * Checks again the manga of [source] that stopped at its captcha, once that captcha is solved. + * A re-check already waiting or running for the source is kept. + */ + fun recheckSource(source: MangaSource) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData(workDataOf(KEY_SOURCE to source.name)) + .addTag(TAG_RECHECK) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueueUniqueWork("${TAG_RECHECK}_${source.name}", ExistingWorkPolicy.KEEP, request) + } + fun observeIsRunning(): Flow { - val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() + val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT, TAG_RECHECK)).build() return workManager.getWorkInfosFlow(query) .map { works -> works.any { x -> x.state == WorkInfo.State.RUNNING } @@ -437,6 +463,8 @@ class TrackWorker @AssistedInject constructor( const val WORKER_NOTIFICATION_ID = 35 const val TAG = "tracking" const val TAG_ONESHOT = "tracking_oneshot" + const val TAG_RECHECK = "tracking_recheck" + const val KEY_SOURCE = "source" const val MAX_PARALLELISM = 6 const val MAX_PER_SOURCE_PARALLELISM = 2 val BATCH_SIZE = if (BuildConfig.DEBUG) 20 else 46 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a76d28f8a4..b378ded572 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,6 +483,8 @@ Manage categories Do not update suggestions using metered network connections Do not check for new chapters using metered network connections + Re-check after solving a CAPTCHA + Check the manga that needed a CAPTCHA again as soon as it is solved Enter manga title, genre or source name Progress Added diff --git a/app/src/main/res/xml/pref_tracker.xml b/app/src/main/res/xml/pref_tracker.xml index 325dd16641..3d9baaf5b7 100644 --- a/app/src/main/res/xml/pref_tracker.xml +++ b/app/src/main/res/xml/pref_tracker.xml @@ -59,6 +59,13 @@ android:title="@string/download_new_chapters" app:useSimpleSummaryProvider="true" /> + + Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 14/27] fix(cloudflare): keep WebView interception alive and remember the clearance User-Agent * fix(webview): make request interception survive Cloudflare interstitials Re-inject the page script on every navigation and poll it every 250 ms while capturing, instead of injecting it once (captures used to time out when a challenge page preceded the real one). Serialise captures with a mutex and reuse a single WebView, releasing it asynchronously instead of synchronously destroying it. Evaluation WebViews in WebViewExecutor are now destroyed after use, and evaluateJs uses cancellation-safe suspendCancellableCoroutine. Ported from Kotatsu-Redo 9.8.2 (dc0de752a). * fix(cloudflare): remember the User-Agent that earned clearance per source When the CloudFlare solver WebView is given a non-Android (e.g. hard-coded desktop) User-Agent that contradicts the engine, realign it with the engine's default UA so Turnstile can pass, and once the check passes store it in SourceSettings.cloudFlareUserAgent. CommonHeadersInterceptor then forces that UA for the source, so OkHttp presents the agent the cf_clearance cookie is bound to. The background WebViewExecutor solver uses the same stored UA. Ported from Kotatsu-Redo 9.8.1 (cb430d3a6). * fix(cloudflare): let the user's User-Agent setting win over the stored clearance agent The agent recorded after a Cloudflare solve was never cleared and silently overrode a User-Agent the user had configured for the source. Record the user's setting alongside it and ignore the stored agent once that changes. --- .../browser/cloudflare/CloudFlareActivity.kt | 37 +++ .../core/network/CommonHeadersInterceptor.kt | 5 + .../RequestInterceptorWebViewClient.kt | 63 +++- .../core/network/webview/WebViewExecutor.kt | 48 ++- .../WebViewRequestInterceptorExecutor.kt | 283 ++++++++---------- .../futon/core/prefs/SourceSettings.kt | 26 ++ 6 files changed, 284 insertions(+), 178 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt index 3782d29c8c..d2ead965bf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem +import android.webkit.WebSettings import androidx.activity.result.contract.ActivityResultContract import androidx.core.view.isInvisible import androidx.lifecycle.lifecycleScope @@ -46,6 +47,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { lateinit var captchaHandler: CaptchaHandler private lateinit var cfClient: CloudFlareClient + private var alignedUserAgent: String? = null override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) @@ -54,6 +56,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { finishAfterTransition() return } + alignUserAgentWithEngine(repository) // Check if source needs header interception val needsInterception = shouldUseInterception(source, repository) @@ -118,6 +121,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { override fun onCheckPassed() { pendingResult = RESULT_OK + rememberAlignedUserAgent() lifecycleScope.launch { val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) if (source != null) { @@ -155,6 +159,38 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { } } + /** + * Turnstile cannot be passed by a WebView that lies about what it is. Some parsers pin a desktop + * User-Agent, which contradicts everything the challenge script measures (`navigator.userAgentData`, + * platform, touch points, WebGL renderer), so the challenge reloads forever. Realign such an agent + * with the engine's own; sources whose agent already looks like Android are left untouched. + */ + private fun alignUserAgentWithEngine(repository: ParserMangaRepository?) { + if (repository == null) return + val engineUserAgent = runCatching { WebSettings.getDefaultUserAgent(this) }.getOrNull() + if (engineUserAgent.isNullOrEmpty() || ANDROID_UA_TOKEN !in engineUserAgent) return + val currentUserAgent = viewBinding.webView.settings.userAgentString + if (currentUserAgent.isNullOrEmpty() || ANDROID_UA_TOKEN in currentUserAgent) return + viewBinding.webView.settings.userAgentString = engineUserAgent + alignedUserAgent = engineUserAgent + } + + /** + * The clearance cookie is bound to the User-Agent that earned it, so OkHttp has to present the same + * one. Writing [ConfigKey.UserAgent] would not do, since parsers with a hard-coded agent never read it. + */ + private fun rememberAlignedUserAgent() { + val userAgent = alignedUserAgent ?: return + val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return + runCatching { + (mangaRepositoryFactory.create(MangaSource(source)) as? ParserMangaRepository) + ?.getConfig() + ?.cloudFlareUserAgent = userAgent + }.onFailure { + it.printStackTraceDebug("CloudFlareActivity::rememberAlignedUserAgent") + } + } + private fun shouldUseInterception(source: MangaSource, repository: ParserMangaRepository?): Boolean { Log.d(TAG, "shouldUseInterception called for source: ${source.name}") Log.d(TAG, "Repository type: ${repository?.javaClass?.simpleName}") @@ -193,5 +229,6 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { companion object { const val TAG = "CloudFlareActivity" + private const val ANDROID_UA_TOKEN = "Android" } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt index 76cda52eae..db59aa6eab 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/CommonHeadersInterceptor.kt @@ -40,6 +40,11 @@ class CommonHeadersInterceptor @Inject constructor( repository?.getRequestHeaders()?.let { headersBuilder.mergeWith(it, replaceExisting = false) } + // Deliberately overrides the parser's agent: a cf_clearance cookie is only honoured for the + // exact User-Agent it was issued to. See SourceSettings.cloudFlareUserAgent. + repository?.getConfig()?.cloudFlareUserAgent?.let { + headersBuilder.trySet(CommonHeaders.USER_AGENT, it) + } if (headersBuilder[CommonHeaders.USER_AGENT] == null) { headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent() } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/RequestInterceptorWebViewClient.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/RequestInterceptorWebViewClient.kt index 7aa9308a82..06d0be6e7a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/RequestInterceptorWebViewClient.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/RequestInterceptorWebViewClient.kt @@ -1,5 +1,8 @@ package io.github.landwarderer.futon.core.network.webview +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper import android.util.Log import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -11,6 +14,7 @@ import kotlinx.coroutines.sync.withLock import io.github.landwarderer.futon.browser.BrowserCallback import io.github.landwarderer.futon.browser.BrowserClient import io.github.landwarderer.futon.core.network.webview.adblock.AdBlock +import java.lang.ref.WeakReference import java.util.Collections import java.util.concurrent.atomic.AtomicBoolean @@ -29,7 +33,8 @@ class RequestInterceptorWebViewClient( private val mutex = Mutex() private val isCapturing = AtomicBoolean(true) private val startTime = System.currentTimeMillis() - private val scriptInjected = AtomicBoolean(false) + private val mainHandler = Handler(Looper.getMainLooper()) + private val pollScheduled = AtomicBoolean(false) @WorkerThread override fun shouldInterceptRequest( @@ -57,15 +62,56 @@ class RequestInterceptorWebViewClient( return super.shouldOverrideUrlLoading(view, request) } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (view != null) { + injectPageScript(view) + } + } + override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) + injectPageScript(view) + schedulePolling(view) + } + + /** + * The script has to be injected again on every navigation, not just the first one: + * an interstitial (a Cloudflare challenge, a redirect) is its own document, and the page + * that replaces it gets a fresh JS context. Scripts are expected to be idempotent. + */ + private fun injectPageScript(view: WebView) { val script = config.pageScript - if (!script.isNullOrBlank() && scriptInjected.compareAndSet(false, true)) { - Log.d(TAG_VRF, "Injecting pageScript for URL: $url") - view.evaluateJavascript(script, null) - } else if (!script.isNullOrBlank()) { - Log.v(TAG_VRF, "PageScript already injected, skipping for URL: $url") + if (script.isNullOrBlank() || !isCapturing.get() || isTimeoutReached()) { + return + } + view.evaluateJavascript(script, null) + } + + /** + * Keeps re-running the script while a page is up, so a payload that only becomes + * available after some in-page work, or after a challenge clears without a navigation, + * is still picked up. + */ + private fun schedulePolling(view: WebView) { + if (config.pageScript.isNullOrBlank() || !pollScheduled.compareAndSet(false, true)) { + return } + val viewRef = WeakReference(view) + mainHandler.postDelayed( + object : Runnable { + override fun run() { + val webView = viewRef.get() + if (webView == null || !isCapturing.get() || isTimeoutReached()) { + pollScheduled.set(false) + return + } + injectPageScript(webView) + mainHandler.postDelayed(this, SCRIPT_POLL_INTERVAL_MS) + } + }, + SCRIPT_POLL_INTERVAL_MS, + ) } /** @@ -139,4 +185,9 @@ class RequestInterceptorWebViewClient( capturedRequests.toList() } } + + private companion object { + + const val SCRIPT_POLL_INTERVAL_MS = 250L + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt index a6636d83e9..a5c38813f1 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt @@ -31,7 +31,6 @@ import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlin.ranges.contains @Singleton @@ -67,15 +66,19 @@ class WebViewExecutor @Inject constructor( try { if (baseUrl.isNullOrEmpty()) { - return@withContext suspendCoroutine { cont -> - webView.evaluateJavascript(script) { cont.resume(it.takeUnless { r -> r == "null" }) } + return@withContext suspendCancellableCoroutine { cont -> + webView.evaluateJavascript(script) { result -> + if (cont.isActive) { + cont.resume(result?.takeUnless { it == "null" }) + } + } } } val baseUri = android.net.Uri.parse(baseUrl) val originalHost = baseUri.host - suspendCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> var hasResumed = false val resumeOnce: (String?) -> Unit = { result -> @@ -84,10 +87,19 @@ class WebViewExecutor @Inject constructor( handler.removeCallbacksAndMessages(null) // Immediately stop further loading/polling webView.stopLoading() - continuation.resume(result) + if (continuation.isActive) { + continuation.resume(result) + } } } + continuation.invokeOnCancellation { + hasResumed = true + handler.removeCallbacksAndMessages(null) + webView.post { runCatching { webView.stopLoading() } } + } + if (!continuation.isActive) return@suspendCancellableCoroutine + val contentPoller = object : Runnable { val startTime = System.currentTimeMillis() override fun run() { @@ -151,8 +163,8 @@ class WebViewExecutor @Inject constructor( }, timeoutMs) } } finally { - // If already resumed, stopLoading() was called; this is a safety call. - webView.stopLoading() + handler.removeCallbacksAndMessages(null) + destroyEvaluatedWebView(webView) } } } @@ -191,9 +203,29 @@ class WebViewExecutor @Inject constructor( webViewCached = WeakReference(it) } + /** + * Evaluation WebViews are intentionally one-shot. Keeping the loaded document alive after returning lets + * media, animation frames, workers, and page timers continue consuming power in the background. + */ + @MainThread + private fun destroyEvaluatedWebView(webView: WebView) { + if (webViewCached?.get() === webView) { + webViewCached = null + } + runCatching { webView.stopLoading() } + runCatching { webView.onPause() } + webView.webViewClient = WebViewClient() + webView.webChromeClient = null + runCatching { webView.loadUrl("about:blank") } + runCatching { webView.clearHistory() } + runCatching { webView.removeAllViews() } + runCatching { webView.destroy() } + } + private fun MangaSource.getUserAgent(): String? { val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository - return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) + return repository?.getConfig()?.cloudFlareUserAgent + ?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) } @MainThread diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewRequestInterceptorExecutor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewRequestInterceptorExecutor.kt index 664e6e0997..d7e644ea2f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewRequestInterceptorExecutor.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewRequestInterceptorExecutor.kt @@ -7,11 +7,14 @@ import android.util.Log import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView +import android.webkit.WebViewClient import androidx.annotation.MainThread import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import io.github.landwarderer.futon.browser.BrowserCallback @@ -33,151 +36,137 @@ class WebViewRequestInterceptorExecutor @Inject constructor( private var webViewCached: WeakReference? = null private val mainHandler = Handler(Looper.getMainLooper()) - private val activeWebViews = mutableSetOf>() + private val mutex = Mutex() suspend fun interceptRequests( url: String, config: InterceptionConfig - ): List = withTimeout(config.timeoutMs + 5000) { - Log.d(TAG_VRF, "interceptRequests start url=$url injectPageScript=${!config.pageScript.isNullOrBlank()} hasFilterScript=${!config.filterScript.isNullOrBlank()}") - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { continuation -> - val resultDeferred = CompletableDeferred>() - - val interceptor = object : WebViewRequestInterceptor { - override fun shouldCaptureRequest(request: InterceptedRequest): Boolean { - val urlOk = config.urlPattern?.containsMatchIn(request.url) ?: true - val scriptOk = try { - if (config.filterScript.isNullOrBlank()) true - else evaluateFilterPredicate(config.filterScript, request.url) - } catch (e: Throwable) { - Log.w(TAG_VRF, "Filter error ${e.message}") - false + ): List = mutex.withLock { + withTimeout(config.timeoutMs + 5000) { + Log.d(TAG_VRF, "interceptRequests start url=$url injectPageScript=${!config.pageScript.isNullOrBlank()} hasFilterScript=${!config.filterScript.isNullOrBlank()}") + withContext(Dispatchers.Main.immediate) { + suspendCancellableCoroutine { continuation -> + val resultDeferred = CompletableDeferred>() + + val interceptor = object : WebViewRequestInterceptor { + override fun shouldCaptureRequest(request: InterceptedRequest): Boolean { + val urlOk = config.urlPattern?.containsMatchIn(request.url) ?: true + val scriptOk = try { + if (config.filterScript.isNullOrBlank()) true + else evaluateFilterPredicate(config.filterScript, request.url) + } catch (e: Throwable) { + Log.w(TAG_VRF, "Filter error ${e.message}") + false + } + val match = urlOk && scriptOk + Log.v(TAG_VRF, "REQ url=${request.url} method=${request.method} urlOk=$urlOk scriptOk=$scriptOk match=$match") + return match } - val match = urlOk && scriptOk - Log.v(TAG_VRF, "REQ url=${request.url} method=${request.method} urlOk=$urlOk scriptOk=$scriptOk match=$match") - return match - } - override fun onInterceptionComplete(capturedRequests: List) { - Log.d(TAG_VRF, "Interception complete captured=${capturedRequests.size}") - if (!resultDeferred.isCompleted) { - resultDeferred.complete(capturedRequests) + override fun onInterceptionComplete(capturedRequests: List) { + Log.d(TAG_VRF, "Interception complete captured=${capturedRequests.size}") + if (!resultDeferred.isCompleted) { + resultDeferred.complete(capturedRequests) + } } - } - override fun onInterceptionError(error: Throwable) { - Log.w(TAG_VRF, "Interception error", error) - if (!resultDeferred.isCompleted) { - resultDeferred.completeExceptionally(error) + override fun onInterceptionError(error: Throwable) { + Log.w(TAG_VRF, "Interception error", error) + if (!resultDeferred.isCompleted) { + resultDeferred.completeExceptionally(error) + } } } - } - val callback = object : BrowserCallback { - override fun onLoadingStateChanged(isLoading: Boolean) { - Log.v(TAG_VRF, "Loading state changed isLoading=$isLoading") - } - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { - Log.v(TAG_VRF, "Title changed title=$title subtitle=$subtitle") - } - override fun onHistoryChanged() { - Log.v(TAG_VRF, "History changed") + val callback = object : BrowserCallback { + override fun onLoadingStateChanged(isLoading: Boolean) { + Log.v(TAG_VRF, "Loading state changed isLoading=$isLoading") + } + override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { + Log.v(TAG_VRF, "Title changed title=$title subtitle=$subtitle") + } + override fun onHistoryChanged() { + Log.v(TAG_VRF, "History changed") + } } - } - var webView: WebView? = null - try { - webView = obtainWebView() - val client = RequestInterceptorWebViewClient(callback, adBlock, config, interceptor) - webView.webViewClient = client - - webView.webChromeClient = object : WebChromeClient() { - override fun onPermissionRequest(request: PermissionRequest?) { - if (request?.resources?.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) == true) { - request.grant(request.resources) - } else { - super.onPermissionRequest(request) + var webView: WebView? = null + try { + webView = obtainWebView() + val client = RequestInterceptorWebViewClient(callback, adBlock, config, interceptor) + webView.webViewClient = client + + webView.webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest?) { + if (request?.resources?.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) == true) { + request.grant(request.resources) + } else { + super.onPermissionRequest(request) + } } } - } - - webView.loadUrl(url) - val timeoutRunnable = Runnable { - Log.w(TAG_VRF, "Timeout, stopping capture") - client.stopCapturing() - } - mainHandler.postDelayed(timeoutRunnable, config.timeoutMs) + webView.loadUrl(url) - resultDeferred.invokeOnCompletion { ex -> - mainHandler.removeCallbacks(timeoutRunnable) - // Clean up WebView after operation completes - must run on main thread - webView?.let { wv -> - Log.d(TAG_VRF, "Cleaning up WebView after operation") - if (Thread.currentThread() == Looper.getMainLooper().thread) { - // Already on main thread, destroy immediately - wv.stopLoading() - wv.destroy() - removeWebViewFromTracking(wv) - if (ex != null) continuation.resumeWithException(ex) - else continuation.resume(resultDeferred.getCompleted()) - } else { - // Must post to main thread, but wait for completion - mainHandler.post { - wv.stopLoading() - wv.destroy() - removeWebViewFromTracking(wv) + val timeoutRunnable = Runnable { + Log.w(TAG_VRF, "Timeout, stopping capture") + client.stopCapturing() + } + mainHandler.postDelayed(timeoutRunnable, config.timeoutMs) + + resultDeferred.invokeOnCompletion { ex -> + mainHandler.removeCallbacks(timeoutRunnable) + // Clean up WebView after operation completes - must run on main thread + webView?.let { wv -> + Log.d(TAG_VRF, "Cleaning up WebView after operation") + if (Thread.currentThread() == Looper.getMainLooper().thread) { + releaseWebView(wv) if (ex != null) continuation.resumeWithException(ex) else continuation.resume(resultDeferred.getCompleted()) + } else { + mainHandler.post { + releaseWebView(wv) + if (ex != null) continuation.resumeWithException(ex) + else continuation.resume(resultDeferred.getCompleted()) + } } + } ?: run { + // No WebView to clean up, resume immediately + if (ex != null) continuation.resumeWithException(ex) + else continuation.resume(resultDeferred.getCompleted()) } - } ?: run { - // No WebView to clean up, resume immediately - if (ex != null) continuation.resumeWithException(ex) - else continuation.resume(resultDeferred.getCompleted()) } - } - continuation.invokeOnCancellation { - client.stopCapturing() - mainHandler.removeCallbacks(timeoutRunnable) - // Clean up WebView on cancellation - must run on main thread + continuation.invokeOnCancellation { + client.stopCapturing() + mainHandler.removeCallbacks(timeoutRunnable) + // Clean up WebView on cancellation - must run on main thread + webView?.let { wv -> + Log.d(TAG_VRF, "Cleaning up WebView on cancellation") + if (Thread.currentThread() == Looper.getMainLooper().thread) { + releaseWebView(wv) + } else { + mainHandler.post { + releaseWebView(wv) + } + } + } + if (!resultDeferred.isCompleted) resultDeferred.cancel() + } + } catch (e: Exception) { + // Clean up WebView on exception - must run on main thread webView?.let { wv -> - Log.d(TAG_VRF, "Cleaning up WebView on cancellation") + Log.d(TAG_VRF, "Cleaning up WebView on exception") if (Thread.currentThread() == Looper.getMainLooper().thread) { - // Already on main thread, destroy immediately - wv.stopLoading() - wv.destroy() - removeWebViewFromTracking(wv) + releaseWebView(wv) + continuation.resumeWithException(e) } else { - // Must post to main thread for cleanup mainHandler.post { - wv.stopLoading() - wv.destroy() - removeWebViewFromTracking(wv) + releaseWebView(wv) + continuation.resumeWithException(e) } } - } - if (!resultDeferred.isCompleted) resultDeferred.cancel() + } ?: continuation.resumeWithException(e) } - } catch (e: Exception) { - // Clean up WebView on exception - must run on main thread - webView?.let { wv -> - Log.d(TAG_VRF, "Cleaning up WebView on exception") - if (Thread.currentThread() == Looper.getMainLooper().thread) { - // Already on main thread, destroy immediately - wv.destroy() - removeWebViewFromTracking(wv) - continuation.resumeWithException(e) - } else { - // Must post to main thread, then resume with exception - mainHandler.post { - wv.destroy() - removeWebViewFromTracking(wv) - continuation.resumeWithException(e) - } - } - } ?: continuation.resumeWithException(e) } } } @@ -199,57 +188,23 @@ class WebViewRequestInterceptorExecutor @Inject constructor( } @MainThread - private fun obtainWebView(): WebView { - // Clean up any previous WebView instances - cleanupOldWebViews() - - val wv = WebView(context).apply { - configureForParser(null) - // Clear any existing state - clearHistory() - clearCache(true) - } - Log.d(TAG_VRF, "Created fresh WebView instance") - - // Track this WebView - val webViewRef = WeakReference(wv) - webViewCached = webViewRef - synchronized(activeWebViews) { - activeWebViews.add(webViewRef) - } - - return wv - } - - @MainThread - private fun cleanupOldWebViews() { - synchronized(activeWebViews) { - val iterator = activeWebViews.iterator() - while (iterator.hasNext()) { - val ref = iterator.next() - val webView = ref.get() - if (webView == null) { - // WebView was garbage collected - iterator.remove() - } else { - // Destroy still-active WebView - Log.d(TAG_VRF, "Destroying previous WebView instance") - webView.destroy() - iterator.remove() - } - } - } - webViewCached = null + private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also { webView -> + webView.configureForParser(null) + webView.clearHistory() + webView.clearCache(true) + webViewCached = WeakReference(webView) } + /** + * The WebView is reused by the next (serialized) capture, which replaces the document with loadUrl(). + * stopLoading() and destroy() can block the main thread for seconds while Chromium tears down, + * so only detach the callbacks and ask the page to stop asynchronously. + */ @MainThread - private fun removeWebViewFromTracking(webView: WebView) { - synchronized(activeWebViews) { - activeWebViews.removeAll { it.get() == webView || it.get() == null } - } - if (webViewCached?.get() == webView) { - webViewCached = null - } + private fun releaseWebView(webView: WebView) { + runCatching { webView.evaluateJavascript("window.stop(); void 0", null) } + webView.webViewClient = WebViewClient() + webView.webChromeClient = WebChromeClient() } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt index 5539b02bc3..4193c27eeb 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt @@ -32,6 +32,29 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig val isCaptchaNotificationsDisabled: Boolean get() = prefs.getBoolean(KEY_NO_CAPTCHA, false) + /** + * The User-Agent that earned this source's current `cf_clearance` cookie. + * + * Cloudflare only honours a clearance for the exact agent it was issued to, and the challenge can + * only be solved by a WebView presenting an agent consistent with its own engine. Parsers that pin + * an inconsistent (e.g. hard-coded desktop) agent in `getRequestHeaders()` would otherwise stay + * blocked no matter how many times the challenge is solved. Set by the solver, applied by + * `CommonHeadersInterceptor`; null means the parser's own agent is fine. + * + * Recorded together with the user's own User-Agent setting at that moment: once the user changes + * that setting, their choice wins again and the stored agent is ignored until the next solve. + */ + var cloudFlareUserAgent: String? + get() { + val value = prefs.getString(KEY_CF_USER_AGENT, null)?.nullIfEmpty() ?: return null + val base = prefs.getString(KEY_CF_USER_AGENT_BASE, null).orEmpty() + return value.takeIf { base == prefs.getString(KEY_USER_AGENT, null).orEmpty() } + } + set(value) = prefs.edit { + putString(KEY_CF_USER_AGENT, value?.sanitizeHeaderValue()) + putString(KEY_CF_USER_AGENT_BASE, prefs.getString(KEY_USER_AGENT, null).orEmpty()) + } + @Suppress("UNCHECKED_CAST") override fun get(key: ConfigKey): T { return when (key) { @@ -80,5 +103,8 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig const val KEY_NO_CAPTCHA = "no_captcha" const val KEY_SLOWDOWN = "slowdown" const val KEY_SORT_ORDER = "sort_order" + const val KEY_CF_USER_AGENT = "cf_user_agent" + const val KEY_CF_USER_AGENT_BASE = "cf_user_agent_base" + private const val KEY_USER_AGENT = "user_agent" // ConfigKey.UserAgent } } From 62bc9fe7f3ab201468a3f67cce185dbc9437dac6 Mon Sep 17 00:00:00 2001 From: PoonHarpoon <52467605+PoonHarpoon@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:58:27 +0000 Subject: [PATCH 15/27] feat(cloudflare): solve captchas automatically on explicit actions (experimental, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a catalog, a manga or a chapter now first tries to pass a CloudFlare challenge silently: a translucent, focused CloudFlareHiddenActivity loads the challenge and a read-only JS page-state probe (CF_STATE_JS) decides when the real page is shown. CaptchaAutoResolveCoordinator owns the single session (de-duplicated, 30 s success / 3 min failure cooldowns, cancelled when the app goes to background, never started from background). CloudFlareActivity gets the resolve loop with viewport wait, stable-pass counting, one auto-recreate with a cleared clearance and a reveal for manual completion; the visible solver uses the same loop. ChaptersLoader and PageLoader run through runWithVerification (prefetch never starts a solve), the reader retries loading details after a solve and the catalog shows "Solving captcha automatically…". Background workers and coil errors never auto-solve. New per-source opt-out "Disable automatic CAPTCHA solving". The headless WebViewExecutor.tryResolveCaptcha and its CaptchaContinuationClient / ContinuationResumeWebViewClient are removed: CaptchaHandler was their only user and it now delegates to the coordinator. AdBlock is kept. Ported from Kotatsu-Redo 9.7.4-9.8.3 (eb2dd236d, d4bb9aff6, c24bd1bb0, b1483ebdd, cb430d3a6, 77d993d92). Automatic solving is experimental and off by default: a global switch in the network settings, "Solve CAPTCHAs automatically (experimental)". SourceSettings.isCaptchaAutoResolveDisabled reads it as well as the per-source opt-out, so every caller follows it: the solver itself, the catalog and the details screen. The per-source switch is greyed out while the global one is off. --- app/src/main/AndroidManifest.xml | 7 + .../alternatives/ui/AlternativesViewModel.kt | 4 +- .../bookmarks/ui/AllBookmarksViewModel.kt | 2 +- .../browser/cloudflare/CloudFlareActivity.kt | 270 +++++++++++++++- .../cloudflare/CloudFlareHiddenActivity.kt | 14 + .../landwarderer/futon/core/AppModule.kt | 8 +- .../resolve/CaptchaAutoResolveCoordinator.kt | 298 ++++++++++++++++++ .../core/exceptions/resolve/CaptchaHandler.kt | 46 ++- .../exceptions/resolve/ExceptionResolver.kt | 30 +- .../landwarderer/futon/core/nav/AppRouter.kt | 11 +- .../webview/CaptchaContinuationClient.kt | 82 ----- .../network/webview/CloudFlareDetection.kt | 45 +++ .../ContinuationResumeWebViewClient.kt | 23 -- .../core/network/webview/WebViewExecutor.kt | 54 ---- .../futon/core/prefs/AppSettings.kt | 4 + .../futon/core/prefs/SourceSettings.kt | 13 + .../futon/core/ui/BaseActivity.kt | 58 ++-- .../core/ui/util/ForegroundActivityHolder.kt | 31 ++ .../futon/details/ui/DetailsErrorObserver.kt | 22 +- .../ui/pager/bookmarks/BookmarksViewModel.kt | 2 +- .../ui/related/RelatedListViewModel.kt | 4 +- .../download/ui/list/DownloadsViewModel.kt | 4 +- .../futon/explore/ui/ExploreViewModel.kt | 2 +- .../FavouritesCategoriesViewModel.kt | 2 +- .../select/FavoriteDialogViewModel.kt | 2 +- .../ui/list/FavouritesListViewModel.kt | 2 +- .../filter/ui/tags/TagsCatalogViewModel.kt | 4 +- .../futon/history/ui/HistoryListViewModel.kt | 2 +- .../futon/list/ui/adapter/LoadingStateAD.kt | 15 +- .../futon/list/ui/model/LoadingState.kt | 6 +- .../picker/ui/manga/MangaPickerViewModel.kt | 2 +- .../futon/reader/domain/ChaptersLoader.kt | 17 +- .../futon/reader/domain/PageLoader.kt | 17 +- .../futon/reader/ui/ReaderViewModel.kt | 13 + .../futon/remotelist/ui/RemoteListFragment.kt | 18 ++ .../remotelist/ui/RemoteListViewModel.kt | 43 ++- .../selector/ScrobblingSelectorViewModel.kt | 2 +- .../futon/search/ui/multi/SearchViewModel.kt | 4 +- .../sources/SourceSettingsFragment.kt | 2 + .../catalog/SourcesCatalogViewModel.kt | 2 +- .../suggestions/ui/SuggestionsViewModel.kt | 2 +- .../futon/suggestions/ui/SuggestionsWorker.kt | 2 +- .../futon/tracker/ui/feed/FeedViewModel.kt | 2 +- .../tracker/ui/updates/UpdatesViewModel.kt | 2 +- .../futon/tracker/work/TrackWorker.kt | 2 +- .../main/res/layout/item_loading_state.xml | 24 +- app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/themes.xml | 16 + app/src/main/res/xml/pref_network_storage.xml | 6 + app/src/main/res/xml/pref_source.xml | 7 + .../futon/reader/domain/ChaptersLoaderTest.kt | 8 +- .../domain/SmartResumeChapterLoadingTest.kt | 8 +- 52 files changed, 1008 insertions(+), 263 deletions(-) create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareHiddenActivity.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaAutoResolveCoordinator.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CaptchaContinuationClient.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CloudFlareDetection.kt delete mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/ContinuationResumeWebViewClient.kt create mode 100644 app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/ForegroundActivityHolder.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 57e1aa03b2..c41f764be9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -230,6 +230,13 @@ android:autoRemoveFromRecents="true" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:windowSoftInputMode="adjustResize" /> + listOf( when { - loading -> LoadingState + loading -> LoadingState() else -> EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, @@ -82,7 +82,7 @@ class AlternativesViewModel @Inject constructor( includeDisabled -> list else -> list + ButtonFooter(R.string.search_disabled_sources) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { doSearch(throughDisabledSources = false) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/bookmarks/ui/AllBookmarksViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/bookmarks/ui/AllBookmarksViewModel.kt index 28e73b94b7..1f0d891fb0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/bookmarks/ui/AllBookmarksViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/bookmarks/ui/AllBookmarksViewModel.kt @@ -48,7 +48,7 @@ class AllBookmarksViewModel @Inject constructor( } } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } - .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) fun removeBookmarks(ids: Set) { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt index d2ead965bf..27ed70ee29 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareActivity.kt @@ -6,25 +6,35 @@ import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem +import android.view.WindowManager +import android.webkit.CookieManager import android.webkit.WebSettings +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContract +import androidx.core.view.doOnLayout +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.yield import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import io.github.landwarderer.futon.R import io.github.landwarderer.futon.browser.BaseBrowserActivity import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaAutoResolveCoordinator import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.model.MangaSource import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.network.cookies.MutableCookieJar +import io.github.landwarderer.futon.core.network.webview.CF_STATE_JS import io.github.landwarderer.futon.core.parser.ParserMangaRepository import io.github.landwarderer.futon.core.util.ext.getDisplayMessage import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug @@ -34,11 +44,27 @@ import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject +import kotlin.coroutines.resume @AndroidEntryPoint -class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { +open class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { + + protected open val isHiddenAutoResolveActivity = false private var pendingResult = RESULT_CANCELED + private val isAutoResolve: Boolean by lazy { intent?.getBooleanExtra(EXTRA_AUTO_RESOLVE, false) == true } + val sessionId: Long by lazy { intent?.getLongExtra(EXTRA_SESSION_ID, 0L) ?: 0L } + private var resultNotified = false + private val autoRecreateCount: Int by lazy { + intent?.getIntExtra(EXTRA_AUTO_RECREATE_COUNT, 0) ?: 0 + } + private var recreateRequested = false + private var clearanceAtLaunch: String? = null + private var clearanceUpdateObservedAt = 0L + private var challengeStartedAt = 0L + private var resolveJob: Job? = null + private var isHiddenPresentation = false + private var hiddenBackPressedCallback: OnBackPressedCallback? = null @Inject lateinit var cookieJar: MutableCookieJar @@ -46,16 +72,40 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { @Inject lateinit var captchaHandler: CaptchaHandler + @Inject + lateinit var captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator + private lateinit var cfClient: CloudFlareClient private var alignedUserAgent: String? = null override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) { - setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + if (isHiddenAutoResolveActivity) { + isHiddenPresentation = true + // Keep the window focused and the WebView rendered: Turnstile treats a non-focused or + // detached WebView differently. The translucent theme exposes the previous screen. + viewBinding.appbar.isGone = true + viewBinding.root.alpha = HIDDEN_RENDER_ALPHA + window.setDimAmount(0f) + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + // Back must cancel the invisible solver, not navigate its WebView history. + hiddenBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = cancelAutomaticResolve() + }.also { onBackPressedDispatcher.addCallback(this, it) } + (this as? CloudFlareHiddenActivity)?.let { + captchaAutoResolveCoordinator.registerHiddenActivity(it, sessionId) + } + if (isFinishing) { + return + } + } else { + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + } val url = intent?.dataString if (url.isNullOrEmpty()) { finishAfterTransition() return } + clearanceAtLaunch = CloudFlareHelper.getClearanceCookie(cookieJar, url) alignUserAgentWithEngine(repository) // Check if source needs header interception @@ -77,14 +127,32 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { } catch (e: Exception) { Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() } - if (savedInstanceState == null) { + if (savedInstanceState == null || autoRecreateCount > 0) { + if (isAutoResolve && autoRecreateCount == 0) { + // The clearance OkHttp has been presenting was rejected: do not let it short-circuit + // the challenge in the WebView. + url.toHttpUrlOrNull()?.let { + clearRejectedClearance(it) + CookieManager.getInstance().flush() + } + } + awaitChallengeViewport() onTitleChanged(getString(R.string.loading_), url) + // Arm the resolve budget only now: the proxy config and the viewport wait above can take a while. + challengeStartedAt = System.currentTimeMillis() viewBinding.webView.loadUrl(url) + } else { + // Restored after a configuration change: the WebView kept its page. + challengeStartedAt = System.currentTimeMillis() } } + // A cf_clearance change is not a completion signal. Cloudflare can update it while the + // interstitial is still running, so only the loaded page state may complete this flow. + resolveJob = lifecycleScope.launch { runResolveLoop() } } override fun onCreateOptionsMenu(menu: Menu?): Boolean { + if (isHiddenPresentation) return false menuInflater.inflate(R.menu.opt_captcha, menu) return super.onCreateOptionsMenu(menu) } @@ -106,29 +174,191 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { override fun finish() { setResult(pendingResult) + notifyAutoResolveResult() super.finish() } + override fun onDestroy() { + if (!isChangingConfigurations && !recreateRequested) { + // Destroyed without finish(): the coordinator must not wait for this solver any longer. + notifyAutoResolveResult() + } + (this as? CloudFlareHiddenActivity)?.let { + captchaAutoResolveCoordinator.unregisterHiddenActivity(it) + } + super.onDestroy() + } + + /** + * In auto-resolve mode the originating screen may already be gone, so its ActivityResultLauncher won't + * deliver the result. Notify the singleton coordinator instead. + */ + private fun notifyAutoResolveResult() { + if (isAutoResolve && !resultNotified) { + resultNotified = true + captchaAutoResolveCoordinator.notifyResolveResult(sessionId, pendingResult == RESULT_OK) + } + } + + /** True only while this same WebView should remain invisible and at the top of the task. */ + fun shouldStayHiddenAndFocused(): Boolean = + isAutoResolve && isHiddenPresentation && !isFinishing && !isDestroyed + + /** Cancels the automatic session without leaving a hidden Activity or WebView behind. */ + fun cancelAutomaticResolve() { + if (!isAutoResolve || isFinishing || isDestroyed) return + resolveJob?.cancel() + if (hasViewBinding()) { + viewBinding.webView.stopLoading() + } + finishAfterTransition() + } + override fun onLoadingStateChanged(isLoading: Boolean) = Unit override fun onPageLoaded() { viewBinding.progressBar.isInvisible = true } - override fun onLoopDetected() { - restartCheck() - } + // The resolve loop decides when the challenge is over; a reloading interstitial is not a failure by itself. + override fun onLoopDetected() = Unit override fun onCheckPassed() { + if (isAutoResolve && autoRecreateCount < MAX_AUTO_RECREATE_COUNT) { + markClearanceUpdateObserved() + } + } + + /** + * Do not start Turnstile until this Activity has a real viewport. The focus wait is bounded: a hidden + * solver that never wins focus must still load its URL. + */ + private suspend fun awaitChallengeViewport() { + val focusDeadline = System.currentTimeMillis() + VIEWPORT_FOCUS_TIMEOUT_MS + while (!viewBinding.webView.hasWindowFocus() && System.currentTimeMillis() < focusDeadline) { + delay(VIEWPORT_POLL_INTERVAL_MS) + } + suspendCancellableCoroutine { cont -> + viewBinding.webView.doOnLayout { + if (cont.isActive) cont.resume(Unit) + } + } + suspendCancellableCoroutine { cont -> + viewBinding.webView.postOnAnimation { + if (cont.isActive) cont.resume(Unit) + } + } + } + + /** + * Drives the challenge to completion. The target page must report [CF_STATE_JS] == "ok" for several + * consecutive probes, so the short DOM gap between two managed-challenge stages is not accepted. + * The probe is a passive, read-only DOM query of the parent page and does not perturb the challenge. + */ + private suspend fun runResolveLoop() { + val budget = if (autoRecreateCount < MAX_AUTO_RECREATE_COUNT) { + AUTO_RETRY_DELAY_MS + } else { + MANUAL_FALLBACK_DELAY_MS + } + var consecutivePasses = 0 + val requiredStablePasses = if (isHiddenAutoResolveActivity && autoRecreateCount > 0) { + HIDDEN_RECREATED_STABLE_PASSES + } else { + REQUIRED_STABLE_PASSES + } + while (true) { + delay(RESOLVE_POLL_INTERVAL_MS) + val startedAt = challengeStartedAt + if (startedAt == 0L) { + continue // nothing is loading yet + } + if (isAutoResolve && autoRecreateCount < MAX_AUTO_RECREATE_COUNT) { + val clearance = intent.dataString?.let { + CloudFlareHelper.getClearanceCookie(cookieJar, it) + } + if (clearance != null && clearance != clearanceAtLaunch) { + markClearanceUpdateObserved() + } + } + if (probeChallengeState() == CF_STATE_OK) { + consecutivePasses++ + if (consecutivePasses >= requiredStablePasses) { + finishSuccess() + return + } + } else { + consecutivePasses = 0 + val now = System.currentTimeMillis() + val retryAt = startedAt + budget + if ( + clearanceUpdateObservedAt != 0L && + now - clearanceUpdateObservedAt >= CLEARANCE_RECREATE_GRACE_MS + ) { + requestAutoRecreate() + return + } + if (isAutoResolve && autoRecreateCount < MAX_AUTO_RECREATE_COUNT && now >= retryAt) { + requestAutoRecreate() + return + } + if (isAutoResolve && autoRecreateCount >= MAX_AUTO_RECREATE_COUNT && now >= retryAt) { + revealForManualCompletion() + } + } + } + } + + private fun markClearanceUpdateObserved() { + if (clearanceUpdateObservedAt == 0L) { + clearanceUpdateObservedAt = System.currentTimeMillis() + } + } + + /** Recreates the solver once, keeping the warmed-up browser profile (cookies, storage). */ + private fun requestAutoRecreate() { + if (recreateRequested || autoRecreateCount >= MAX_AUTO_RECREATE_COUNT) return + recreateRequested = true + resolveJob?.cancel() + CookieManager.getInstance().flush() + intent.putExtra(EXTRA_AUTO_RECREATE_COUNT, autoRecreateCount + 1) + recreate() + } + + /** Falls back to manual completion without discarding the warmed browser profile or WebView. */ + private fun revealForManualCompletion() { + if (!isHiddenPresentation) return + isHiddenPresentation = false + viewBinding.root.alpha = 1f + viewBinding.appbar.isGone = false + window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + hiddenBackPressedCallback?.isEnabled = false + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + invalidateOptionsMenu() + } + + private suspend fun probeChallengeState(): String = suspendCancellableCoroutine { cont -> + viewBinding.webView.evaluateJavascript(CF_STATE_JS) { raw -> + if (cont.isActive) { + cont.resume(raw?.removeSurrounding("\"") ?: CF_STATE_WAIT) + } + } + } + + private fun finishSuccess() { + if (pendingResult == RESULT_OK) return pendingResult = RESULT_OK + resolveJob?.cancel() rememberAlignedUserAgent() lifecycleScope.launch { + // Persist the final cookie state before the WebView is destroyed and the parser retries. + CookieManager.getInstance().flush() val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) if (source != null) { runCatchingCancellable { captchaHandler.discard(MangaSource(source)) }.onFailure { - it.printStackTraceDebug("CloudFlareActivity::onCheckPassed") + it.printStackTraceDebug("CloudFlareActivity::finishSuccess") } } finishAfterTransition() @@ -142,17 +372,27 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { private fun restartCheck() { lifecycleScope.launch { + resolveJob?.cancel() viewBinding.webView.stopLoading() yield() cfClient.reset() val targetUrl = intent?.dataString?.toHttpUrlOrNull() if (targetUrl != null) { clearCfCookies(targetUrl) + clearanceUpdateObservedAt = 0L + challengeStartedAt = System.currentTimeMillis() viewBinding.webView.loadUrl(targetUrl.toString()) + resolveJob = lifecycleScope.launch { runResolveLoop() } } } } + private suspend fun clearRejectedClearance(url: HttpUrl) = runInterruptible(Dispatchers.IO) { + cookieJar.removeCookies(url) { cookie -> + cookie.name == CLEARANCE_COOKIE_NAME + } + } + private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.IO) { cookieJar.removeCookies(url) { cookie -> CloudFlareHelper.isCloudFlareCookie(cookie.name) @@ -229,6 +469,22 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { companion object { const val TAG = "CloudFlareActivity" + const val EXTRA_AUTO_RESOLVE = "auto_resolve" + const val EXTRA_SESSION_ID = "auto_resolve_session" + private const val EXTRA_AUTO_RECREATE_COUNT = "auto_recreate_count" + private const val VIEWPORT_POLL_INTERVAL_MS = 16L + private const val VIEWPORT_FOCUS_TIMEOUT_MS = 2_000L + private const val RESOLVE_POLL_INTERVAL_MS = 800L + private const val AUTO_RETRY_DELAY_MS = 6_000L + private const val MANUAL_FALLBACK_DELAY_MS = 15_000L + private const val CLEARANCE_RECREATE_GRACE_MS = 500L + private const val REQUIRED_STABLE_PASSES = 3 + private const val HIDDEN_RECREATED_STABLE_PASSES = 1 + private const val MAX_AUTO_RECREATE_COUNT = 1 + private const val CLEARANCE_COOKIE_NAME = "cf_clearance" + private const val HIDDEN_RENDER_ALPHA = 0.01f + private const val CF_STATE_OK = "ok" + private const val CF_STATE_WAIT = "wait" private const val ANDROID_UA_TOKEN = "Android" } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareHiddenActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareHiddenActivity.kt new file mode 100644 index 0000000000..a0524496c1 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/browser/cloudflare/CloudFlareHiddenActivity.kt @@ -0,0 +1,14 @@ +package io.github.landwarderer.futon.browser.cloudflare + +import dagger.hilt.android.AndroidEntryPoint + +/** + * Automatic Cloudflare resolver that remains focused and rendered behind a translucent window. + */ +@AndroidEntryPoint +class CloudFlareHiddenActivity : CloudFlareActivity() { + + override val applyColorSchemeTheme: Boolean = false + + override val isHiddenAutoResolveActivity = true +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt index c5d07a2c12..d125577858 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/AppModule.kt @@ -28,6 +28,7 @@ import dagger.multibindings.ElementsIntoSet import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.backups.domain.BackupObserver import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaAutoResolveCoordinator import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaHandler import io.github.landwarderer.futon.core.image.AvifImageDecoder import io.github.landwarderer.futon.core.image.CbzFetcher @@ -41,6 +42,7 @@ import io.github.landwarderer.futon.core.parser.favicon.FaviconFetcher import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.ui.image.CoilImageGetter import io.github.landwarderer.futon.core.ui.util.ActivityRecreationHandle +import io.github.landwarderer.futon.core.ui.util.ForegroundActivityHolder import io.github.landwarderer.futon.core.util.FileSize import io.github.landwarderer.futon.core.util.ext.connectivityManager import io.github.landwarderer.futon.core.util.ext.isLowRamDevice @@ -174,11 +176,15 @@ interface AppModule { appProtectHelper: AppProtectHelper, activityRecreationHandle: ActivityRecreationHandle, screenshotPolicyHelper: ScreenshotPolicyHelper, + foregroundActivityHolder: ForegroundActivityHolder, + captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator, ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( appProtectHelper, activityRecreationHandle, screenshotPolicyHelper, - ) + foregroundActivityHolder, + captchaAutoResolveCoordinator, + ) @Provides @Singleton diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaAutoResolveCoordinator.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaAutoResolveCoordinator.kt new file mode 100644 index 0000000000..97773ff4d6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaAutoResolveCoordinator.kt @@ -0,0 +1,298 @@ +package io.github.landwarderer.futon.core.exceptions.resolve + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.browser.cloudflare.CloudFlareActivity +import io.github.landwarderer.futon.browser.cloudflare.CloudFlareHiddenActivity +import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException +import io.github.landwarderer.futon.core.model.UnknownMangaSource +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.prefs.SourceSettings +import io.github.landwarderer.futon.core.ui.DefaultActivityLifecycleCallbacks +import io.github.landwarderer.futon.core.ui.util.ForegroundActivityHolder +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the single automatic Cloudflare verification session allowed in the app process. + * + * A session is a [CloudFlareHiddenActivity] started on top of the foreground screen. Sessions are only + * started for explicit user actions (opening a catalog, a manga or a chapter); background work may wait + * for a running session but never starts one. + */ +@Singleton +class CaptchaAutoResolveCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + private val foregroundActivityHolder: ForegroundActivityHolder, +) : DefaultActivityLifecycleCallbacks, DefaultLifecycleObserver { + + private val stateMutex = Mutex() + private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } + private val recentSuccessAt = ConcurrentHashMap() + private val recentFailureAt = ConcurrentHashMap() + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } + private val reorderPending = AtomicBoolean(false) + private val isProcessObserverRegistered = AtomicBoolean(false) + private val sessionIdCounter = AtomicLong(0L) + + @Volatile + private var activeSession: ResolveSession? = null + + @Volatile + private var hiddenActivityRef: WeakReference? = null + + fun registerHiddenActivity(activity: CloudFlareHiddenActivity, sessionId: Long) { + val session = activeSession?.takeIf { it.id == sessionId } + if (session == null) { + // A solver of a session which has already ended (e.g. restored by the system): it must not linger. + activity.finish() + return + } + hiddenActivityRef = WeakReference(activity) + reorderPending.set(false) + session.launched.complete(Unit) + } + + fun unregisterHiddenActivity(activity: CloudFlareHiddenActivity) { + if (hiddenActivityRef?.get() === activity) { + hiddenActivityRef = null + } + } + + /** Called by the solver Activity of session [sessionId] when it finishes or is cancelled. */ + fun notifyResolveResult(sessionId: Long, success: Boolean) { + activeSession + ?.takeIf { it.id == sessionId } + ?.activityResult + ?.complete(success) + } + + fun isResolveActive(): Boolean = activeSession != null + + /** Waits for whichever session is active. This method never starts verification. */ + suspend fun awaitActiveResolve(): Boolean? = activeSession?.result?.await() + + /** + * Runs an operation in sync with the solver. Background and prefetch callers pass + * [mayStartVerification] as false: they may wait for an existing session but never create one. + */ + suspend fun runWithVerification( + mayStartVerification: Boolean, + block: suspend () -> T, + ): T { + activeSession?.result?.await() + var didVerify = false + while (true) { + try { + return block() + } catch (e: Exception) { + val cf = e.findCloudFlareException() + if (cf !is CloudFlareProtectedException || !mayStartVerification) throw e + if (didVerify) { + // Verification reported success and this request is *still* Cloudflare-protected. + // Another identical session cannot fix that, so stop asking for one. + notifyVerificationIneffective(cf.source) + throw e + } + if (!resolveIfEnabled(cf)) throw e + didVerify = true + } + } + } + + /** + * Reports that verification which finished successfully did not actually unblock [source] + * (e.g. Cloudflare does not honour the clearance for OkHttp). Suppresses automatic solving for a + * while so the app does not re-solve the same challenge forever. + */ + fun notifyVerificationIneffective(source: MangaSource) { + recentSuccessAt.remove(source) + recentFailureAt[source] = System.currentTimeMillis() + } + + /** Resolves [exception] only when automatic solving is enabled for its source. */ + suspend fun resolveIfEnabled(exception: CloudFlareProtectedException): Boolean { + val source = exception.source + if (source == UnknownMangaSource) return false + if (SourceSettings(context, source).isCaptchaAutoResolveDisabled) return false + val now = System.currentTimeMillis() + val lastSuccess = recentSuccessAt[source] + if (lastSuccess != null && now - lastSuccess < RECENT_SUCCESS_COOLDOWN_MS) { + // Requests that started before the recent session finished: just let them retry. + return true + } + // A source whose challenge just failed must not spawn a solver for every following request. + // Report "not resolved" instead so the caller falls back to the ordinary manual "Solve". + val lastFailure = recentFailureAt[source] + if (lastFailure != null && now - lastFailure < RECENT_FAILURE_COOLDOWN_MS) { + return false + } + return resolve(exception) + } + + /** Joins the current session for the same source or atomically creates the only allowed session. */ + private suspend fun resolve(exception: CloudFlareProtectedException): Boolean { + val source = exception.source + while (true) { + val claim = stateMutex.withLock { + activeSession?.let { return@withLock SessionClaim(it, isOwner = false) } + if (foregroundActivityHolder.current == null) { + // The app is in background: never start a solver from there. + return false + } + val session = ResolveSession( + id = sessionIdCounter.incrementAndGet(), + source = source, + exception = exception, + ) + activeSession = session + SessionClaim(session, isOwner = true) + } + if (claim.isOwner) { + showSolvingToast() + scope.launch { runSession(claim.session) } + } + val result = claim.session.result.await() + if (claim.session.source == source) { + return result + } + // That session was solving another source, ours is still protected: try to claim the next one. + } + } + + private suspend fun runSession(session: ResolveSession) { + var success = false + try { + if (launch(session)) { + val launched = withTimeoutOrNull(LAUNCH_TIMEOUT_MS) { session.launched.await() } + if (launched != null) { + success = session.activityResult.await() + } + } + } catch (e: Throwable) { + e.printStackTraceDebug("CaptchaAutoResolveCoordinator::runSession") + } finally { + if (success) { + recentSuccessAt[session.source] = System.currentTimeMillis() + recentFailureAt.remove(session.source) + } else if (!session.isCancelledByBackground) { + // Backgrounding is the user's choice, not a verdict on the challenge, so it must not + // arm the cooldown. + recentFailureAt[session.source] = System.currentTimeMillis() + } + stateMutex.withLock { + if (activeSession === session) activeSession = null + } + hiddenActivityRef?.get()?.takeIf { it.sessionId == session.id }?.cancelAutomaticResolve() + hiddenActivityRef = null + reorderPending.set(false) + // Clear ownership before releasing waiters, so they can claim the next session. + session.result.complete(success) + } + } + + private fun launch(session: ResolveSession): Boolean { + val launcher = foregroundActivityHolder.current ?: return false + val intent = AppRouter.cloudFlareResolveIntent(launcher, session.exception, hidden = true).apply { + putExtra(CloudFlareActivity.EXTRA_AUTO_RESOLVE, true) + putExtra(CloudFlareActivity.EXTRA_SESSION_ID, session.id) + } + return runCatching { + launcher.startActivity(intent) + }.onFailure { + it.printStackTraceDebug("CaptchaAutoResolveCoordinator::launch") + }.isSuccess + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + // Registered lazily, so that no session can ever outlive the app going to background. + if (isProcessObserverRegistered.compareAndSet(false, true)) { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + } + + /** Keep the existing hidden WebView above newly opened app screens without constructing another one. */ + override fun onActivityResumed(activity: Activity) { + if (activity is CloudFlareActivity) { + reorderPending.set(false) + return + } + val solver = hiddenActivityRef?.get()?.takeUnless { it.isFinishing || it.isDestroyed } ?: return + if (solver.taskId != activity.taskId) return // reordering cannot reach another task, it would create a copy + if (!solver.shouldStayHiddenAndFocused() || !reorderPending.compareAndSet(false, true)) return + mainHandler.post { + if (!solver.shouldStayHiddenAndFocused() || activity.isFinishing || activity.isDestroyed) { + reorderPending.set(false) + return@post + } + activity.startActivity( + Intent(activity, CloudFlareHiddenActivity::class.java).addFlags( + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP, + ), + ) + } + } + + /** App backgrounding cancels the session and therefore releases every waiter with failure. */ + override fun onStop(owner: LifecycleOwner) { + val session = activeSession ?: return + session.isCancelledByBackground = true + session.activityResult.complete(false) + } + + private fun showSolvingToast() { + mainHandler.post { + Toast.makeText(context, R.string.captcha_solving, Toast.LENGTH_LONG).show() + } + } + + private class ResolveSession( + val id: Long, + val source: MangaSource, + val exception: CloudFlareProtectedException, + ) { + val launched = CompletableDeferred() + val activityResult = CompletableDeferred() + val result = CompletableDeferred() + + @Volatile + var isCancelledByBackground = false + } + + private class SessionClaim( + val session: ResolveSession, + val isOwner: Boolean, + ) + + private companion object { + + const val RECENT_SUCCESS_COOLDOWN_MS = 30_000L + const val RECENT_FAILURE_COOLDOWN_MS = 3 * 60_000L + const val LAUNCH_TIMEOUT_MS = 10_000L + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt index 2fdde391b4..e3abe98b5f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/CaptchaHandler.kt @@ -36,7 +36,6 @@ import io.github.landwarderer.futon.core.model.UnknownMangaSource import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.nav.AppRouter -import io.github.landwarderer.futon.core.network.webview.WebViewExecutor import io.github.landwarderer.futon.core.parser.favicon.faviconUri import io.github.landwarderer.futon.core.prefs.SourceSettings import io.github.landwarderer.futon.core.util.ext.checkNotificationPermission @@ -70,7 +69,7 @@ class CaptchaHandler @Inject constructor( @LocalizedAppContext private val context: Context, private val databaseProvider: Provider, private val coilProvider: Provider, - private val webViewExecutor: WebViewExecutor, + private val captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator, ) : EventListener() { private val exceptionMap = MutableScatterMap() @@ -80,12 +79,19 @@ class CaptchaHandler @Inject constructor( /** Sources whose captcha was just solved, by the user or automatically. */ val solvedSources: SharedFlow = solvedSourcesFlow.asSharedFlow() + /** + * Records [exception] and notifies the user about it. + * + * @param tryAutoResolve whether a silent solver may be started first. Background work must pass `false`: + * the solver is an Activity and may only run for an explicit user action. + */ @CheckResult - suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true) + suspend fun handle(exception: CloudFlareException, tryAutoResolve: Boolean): Boolean = + handleException(exception.source, exception, notify = true, tryAutoResolve = tryAutoResolve) /** Called once the captcha of [source] has been solved. */ suspend fun discard(source: MangaSource) { - handleException(source, null, true) + handleException(source, null, notify = true, tryAutoResolve = false) solvedSourcesFlow.emit(source) } @@ -95,15 +101,14 @@ class CaptchaHandler @Inject constructor( if (e is CloudFlareException) { val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope scope.launch { - if ( - handleException( - source = e.source, - exception = e, - notify = request.extras[suppressCaptchaKey] != true, - ) - ) { - coilProvider.get().enqueue(request) // TODO check if ok - } + // Never auto-resolve from coil's error path: every failed favicon / cover load would queue up + // a solver. Auto-resolve only happens for explicit interactions (catalog, details, reader). + handleException( + source = e.source, + exception = e, + notify = request.extras[suppressCaptchaKey] != true, + tryAutoResolve = false, + ) } } } @@ -112,11 +117,16 @@ class CaptchaHandler @Inject constructor( source: MangaSource, exception: CloudFlareException?, notify: Boolean, + tryAutoResolve: Boolean, ): Boolean = withContext(Dispatchers.IO) { if (source == UnknownMangaSource) { return@withContext false } - if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) { + if ( + tryAutoResolve && + exception is CloudFlareProtectedException && + captchaAutoResolveCoordinator.resolveIfEnabled(exception) + ) { return@withContext true } mutex.withLock { @@ -277,7 +287,12 @@ class CaptchaHandler @Inject constructor( override fun onReceive(context: Context?, intent: Intent?) { val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return goAsync { - captchaHandler.handleException(MangaSource(sourceName), exception = null, notify = false) + captchaHandler.handleException( + source = MangaSource(sourceName), + exception = null, + notify = false, + tryAutoResolve = false, + ) } } } @@ -297,6 +312,5 @@ class CaptchaHandler @Inject constructor( private const val SETTINGS_ACTION_CODE = 3 private const val ACTION_DISCARD = "io.github.landwarderer.futon.CAPTCHA_DISCARD" private const val SOLVED_BUFFER_CAPACITY = 8 - private const val RESOLVE_TIMEOUT = 20_000L } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt index 017b8e6a7c..1a54a0af2d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt @@ -45,6 +45,7 @@ import kotlin.coroutines.suspendCoroutine class ExceptionResolver private constructor( private val host: Host, private val settings: AppSettings, + private val captchaCoordinator: CaptchaAutoResolveCoordinator, private val scrobblerAuthHelperProvider: Provider, ) { private val continuations = MutableScatterMap>(1) @@ -63,9 +64,14 @@ class ExceptionResolver private constructor( host.router.showErrorDialog(e, url) } - suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async { + /** + * @param tryAutoResolve whether a CloudFlare captcha may be solved silently first (see + * [CaptchaAutoResolveCoordinator]). Only pass `true` for an explicit user action such as opening a catalog + * or a manga; a "Solve" button press wants the visible solver. + */ + suspend fun resolve(e: Throwable, tryAutoResolve: Boolean = false): Boolean = host.lifecycleScope.async { when (e) { - is CloudFlareProtectedException -> resolveCF(e) + is CloudFlareProtectedException -> resolveCF(e, tryAutoResolve) is AuthRequiredException -> resolveAuthException(e.source) is SSLException, is CertPathValidatorException -> { @@ -123,9 +129,20 @@ class ExceptionResolver private constructor( browserActionContract.launch(e) } - private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont -> - continuations[CloudFlareActivity.TAG] = cont - cloudflareContract.launch(e) + private suspend fun resolveCF(e: CloudFlareProtectedException, tryAutoResolve: Boolean): Boolean { + if (captchaCoordinator.isResolveActive()) { + // Never stack a visible solver on top of the silent one: wait for it instead. + return captchaCoordinator.awaitActiveResolve() == true + } + if (tryAutoResolve) { + // Delegated to the singleton coordinator: it owns the solver lifecycle, so the result is delivered + // even if this screen dies while the solver is still running. + return captchaCoordinator.resolveIfEnabled(e) + } + return suspendCoroutine { cont -> + continuations[CloudFlareActivity.TAG] = cont + cloudflareContract.launch(e) + } } private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> @@ -165,18 +182,21 @@ class ExceptionResolver private constructor( class Factory @Inject constructor( private val settings: AppSettings, + private val captchaCoordinator: CaptchaAutoResolveCoordinator, private val scrobblerAuthHelperProvider: Provider, ) { fun create(fragment: Fragment) = ExceptionResolver( host = Host.FragmentHost(fragment), settings = settings, + captchaCoordinator = captchaCoordinator, scrobblerAuthHelperProvider = scrobblerAuthHelperProvider, ) fun create(activity: FragmentActivity) = ExceptionResolver( host = Host.ActivityHost(activity), settings = settings, + captchaCoordinator = captchaCoordinator, scrobblerAuthHelperProvider = scrobblerAuthHelperProvider, ) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt index 0940c8520c..3bd0e0aeb6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/nav/AppRouter.kt @@ -30,6 +30,7 @@ import io.github.landwarderer.futon.backups.ui.restore.RestoreDialogFragment import io.github.landwarderer.futon.bookmarks.ui.AllBookmarksActivity import io.github.landwarderer.futon.browser.BrowserActivity import io.github.landwarderer.futon.browser.cloudflare.CloudFlareActivity +import io.github.landwarderer.futon.browser.cloudflare.CloudFlareHiddenActivity import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.image.CoilMemoryCacheKey import io.github.landwarderer.futon.core.model.FavouriteCategory @@ -727,14 +728,20 @@ class AppRouter private constructor( } } - fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = - Intent(context, CloudFlareActivity::class.java).apply { + fun cloudFlareResolveIntent( + context: Context, + exception: CloudFlareProtectedException, + hidden: Boolean = false, + ): Intent { + val activityClass = if (hidden) CloudFlareHiddenActivity::class.java else CloudFlareActivity::class.java + return Intent(context, activityClass).apply { data = exception.url.toUri() putExtra(KEY_SOURCE, exception.source.name) exception.headers[CommonHeaders.USER_AGENT]?.let { putExtra(KEY_USER_AGENT, it) } } + } fun browserIntent( context: Context, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CaptchaContinuationClient.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CaptchaContinuationClient.kt deleted file mode 100644 index e2ae4a1c76..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CaptchaContinuationClient.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.github.landwarderer.futon.core.network.webview - -import android.graphics.Bitmap -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import io.github.landwarderer.futon.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.parsers.network.CloudFlareHelper -import kotlin.coroutines.Continuation -import okhttp3.OkHttpClient -import okhttp3.Request -import java.util.concurrent.TimeUnit -import kotlin.toString - -class CaptchaContinuationClient( - private val cookieJar: MutableCookieJar, - private val targetUrl: String, - continuation: Continuation, -) : ContinuationResumeWebViewClient(continuation) { - - private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl) - - override fun onPageFinished(view: WebView?, url: String?) = Unit - - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - super.onPageStarted(view, url, favicon) - checkClearance(view) - } - - override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { - if (request == null) return null - - try { - // Skip POST requests - if (request.method == "POST") { - return super.shouldInterceptRequest(view, request) - } - - val client = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - - val requestBuilder = Request.Builder() - .url(request.url.toString()) - .method(request.method, null) - - val blockedHeaders = setOf( - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "x-requested-with", - ) - // Add all headers except blocked - request.requestHeaders.forEach { (key, value) -> - if (key.lowercase() !in blockedHeaders) { - requestBuilder.addHeader(key, value) - } - } - - val response = client.newCall(requestBuilder.build()).execute() - - val contentType = response.header("Content-Type", "text/html") - val mimeType = contentType?.split(";")?.get(0)?.trim() ?: "text/html" - val charset = contentType?.substringAfter("charset=", "UTF-8")?.trim() ?: "UTF-8" - - return WebResourceResponse(mimeType, charset, response.body?.byteStream()).apply { - val headers = mutableMapOf() - response.headers.forEach { headers[it.first] = it.second } - setResponseHeaders(headers) - } - } catch (e: Exception) { - return null - } - } - private fun checkClearance(view: WebView?) { - val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl) - if (clearance != null && clearance != oldClearance) { - resumeContinuation(view) - } - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CloudFlareDetection.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CloudFlareDetection.kt new file mode 100644 index 0000000000..350f093bb6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/CloudFlareDetection.kt @@ -0,0 +1,45 @@ +package io.github.landwarderer.futon.core.network.webview + +/** + * Returns one of: + * - "ok" — the real page is shown (no Cloudflare interstitial markers, body has content, which + * may be nothing but text: an ajax endpoint answering "0" is a loaded page) + * - "error" — hard-blocked ("Attention Required" / "Access Denied" title) + * - "wait" — page is empty / still loading / still showing a CF challenge + * + * Used to detect that a CloudFlare challenge has finished even when the `cf_clearance` cookie doesn't + * actually change (e.g. when the WebView's fingerprint passes the challenge silently while OkHttp's + * doesn't, so we never get an `onPageStarted` / clearance-cookie diff to signal success). + */ +internal const val CF_STATE_JS = """ + (function(){ + try { + var href = (document.location && document.location.href) || ''; + if (href === '' || href === 'about:blank') return 'wait'; + if (document.readyState !== 'interactive' && document.readyState !== 'complete') return 'wait'; + var t = (document.title || '').toLowerCase(); + if (t.indexOf('attention required') !== -1 || t.indexOf('access denied') !== -1) return 'error'; + if (t.indexOf('just a moment') !== -1 || t.indexOf('un instant') !== -1 || + t.indexOf('einen moment') !== -1 || t.indexOf('un momento') !== -1 || + t.indexOf('один момент') !== -1) return 'wait'; + var challengeNodes = document.querySelectorAll( + '#challenge-running, #challenge-stage, #cf-challenge-running, ' + + '.cf-browser-verification, #turnstile-wrapper, #cf-please-wait' + ); + for (var i = 0; i < challengeNodes.length; i++) { + var node = challengeNodes[i]; + var style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue; + var rect = node.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) return 'wait'; + } + var body = document.body; + if (!body) return 'wait'; + // An endpoint may legitimately answer with a bare text node - WordPress admin-ajax.php + // replies with "0" - and children counts elements only, so an empty element list is not + // proof the page is still loading. Only treat it as unfinished when there is no text either. + if (body.children.length === 0 && (body.textContent || '').trim().length === 0) return 'wait'; + return 'ok'; + } catch (e) { return 'wait'; } + })() +""" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/ContinuationResumeWebViewClient.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/ContinuationResumeWebViewClient.kt deleted file mode 100644 index b2eabc06ec..0000000000 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/ContinuationResumeWebViewClient.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.landwarderer.futon.core.network.webview - -import android.webkit.WebView -import android.webkit.WebViewClient -import kotlinx.coroutines.CancellableContinuation -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -open class ContinuationResumeWebViewClient( - private val continuation: Continuation, -) : WebViewClient() { - - override fun onPageFinished(view: WebView?, url: String?) { - resumeContinuation(view) - } - - protected fun resumeContinuation(view: WebView?) { - if (continuation !is CancellableContinuation || continuation.isActive) { - view?.webViewClient = WebViewClient() // reset to default - continuation.resume(Unit) - } - } -} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt index a5c38813f1..9fee4821a8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/network/webview/WebViewExecutor.kt @@ -13,22 +13,13 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import io.github.landwarderer.futon.core.exceptions.CloudFlareException -import io.github.landwarderer.futon.core.network.CommonHeaders -import io.github.landwarderer.futon.core.network.cookies.MutableCookieJar import io.github.landwarderer.futon.core.network.proxy.ProxyProvider -import io.github.landwarderer.futon.core.parser.MangaRepository -import io.github.landwarderer.futon.core.parser.ParserMangaRepository import io.github.landwarderer.futon.core.util.ext.configureForParser import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.sanitizeHeaderValue -import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.nullIfEmpty -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.lang.ref.WeakReference import javax.inject.Inject -import javax.inject.Provider import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.ranges.contains @@ -37,8 +28,6 @@ import kotlin.ranges.contains class WebViewExecutor @Inject constructor( @ApplicationContext private val context: Context, private val proxyProvider: ProxyProvider, - private val cookieJar: MutableCookieJar, - private val mangaRepositoryFactoryProvider: Provider, ) { private var webViewCached: WeakReference? = null @@ -169,34 +158,6 @@ class WebViewExecutor @Inject constructor( } } - suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock { - runCatchingCancellable { - withContext(Dispatchers.Main.immediate) { - val webView = obtainWebView() - try { - exception.source.getUserAgent()?.let { - webView.settings.userAgentString = it - } - withTimeout(timeout) { - suspendCancellableCoroutine { cont -> - webView.webViewClient = CaptchaContinuationClient( - cookieJar = cookieJar, - targetUrl = exception.url, - continuation = cont, - ) - webView.loadUrl(exception.url) - } - } - } finally { - webView.reset() - } - } - }.onFailure { e -> - exception.addSuppressed(e) - e.printStackTraceDebug("WebViewExecutor::tryResolveCaptcha") - }.isSuccess - } - @MainThread private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(context).also { it.configureForParser(null) @@ -222,25 +183,10 @@ class WebViewExecutor @Inject constructor( runCatching { webView.destroy() } } - private fun MangaSource.getUserAgent(): String? { - val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository - return repository?.getConfig()?.cloudFlareUserAgent - ?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) - } - @MainThread fun getDefaultUserAgentSync() = runCatching { obtainWebView().settings.userAgentString.sanitizeHeaderValue().trim().nullIfEmpty() }.onFailure { e -> e.printStackTraceDebug() }.getOrNull() - - @MainThread - private fun WebView.reset() { - stopLoading() - webViewClient = WebViewClient() - settings.userAgentString = defaultUserAgent - loadDataWithBaseURL(null, " ", "text/html", null, null) - clearHistory() - } } 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 cd1a875c89..7dbf422e4f 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 @@ -424,6 +424,9 @@ class AppSettings internal constructor( val isAdBlockEnabled: Boolean get() = prefs.getBoolean(KEY_ADBLOCK, false) + val isCaptchaAutoSolveEnabled: Boolean + get() = prefs.getBoolean(KEY_CAPTCHA_AUTO_SOLVE, false) + var userSpecifiedMangaDirectories: Set get() { val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty() @@ -835,6 +838,7 @@ class AppSettings internal constructor( const val TRACK_FAVOURITES = "favourites" const val KEY_ADBLOCK = "adblock" + const val KEY_CAPTCHA_AUTO_SOLVE = "captcha_auto_solve" const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE_HISTORY = "list_mode_history" const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt index 4193c27eeb..ec038bb763 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/SourceSettings.kt @@ -3,6 +3,7 @@ package io.github.landwarderer.futon.core.prefs import android.content.Context import android.content.SharedPreferences.OnSharedPreferenceChangeListener import androidx.core.content.edit +import androidx.preference.PreferenceManager import io.github.landwarderer.futon.core.util.ext.getEnumValue import io.github.landwarderer.futon.core.util.ext.putEnumValue import io.github.landwarderer.futon.core.util.ext.sanitizeHeaderValue @@ -17,6 +18,8 @@ import java.io.File class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { + private val appContext = context.applicationContext + private val prefs = context.getSharedPreferences( source.name.replace(File.separatorChar, '$'), Context.MODE_PRIVATE, @@ -32,6 +35,15 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig val isCaptchaNotificationsDisabled: Boolean get() = prefs.getBoolean(KEY_NO_CAPTCHA, false) + /** + * Automatic solving is experimental and opt-in: it stays off unless it is enabled globally, + * and can still be turned off for a single source. + */ + val isCaptchaAutoResolveDisabled: Boolean + get() = !PreferenceManager.getDefaultSharedPreferences(appContext) + .getBoolean(AppSettings.KEY_CAPTCHA_AUTO_SOLVE, false) || + prefs.getBoolean(KEY_NO_AUTO_CAPTCHA, false) + /** * The User-Agent that earned this source's current `cf_clearance` cookie. * @@ -101,6 +113,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig const val KEY_DOMAIN = "domain" const val KEY_NO_CAPTCHA = "no_captcha" + const val KEY_NO_AUTO_CAPTCHA = "no_auto_captcha" const val KEY_SLOWDOWN = "slowdown" const val KEY_SORT_ORDER = "sort_order" const val KEY_CF_USER_AGENT = "cf_user_agent" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt index bfaa72c7ae..8e331865bd 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/BaseActivity.kt @@ -60,36 +60,46 @@ abstract class BaseActivity : super.attachBaseContext(newBase) } + /** + * If true (default), apply the app's color-scheme theme overlay on top of the manifest theme. + * Override to false for activities whose manifest theme must be preserved as-is. + */ + protected open val applyColorSchemeTheme: Boolean = true + override fun onCreate(savedInstanceState: Bundle?) { val settings = entryPoint.settings isAmoledTheme = settings.isAmoledTheme - val importedTheme = settings.selectedCustomTheme.takeIf { - settings.colorScheme == ColorScheme.CUSTOM && DynamicColors.isDynamicColorAvailable() - } - val colorScheme = when { - importedTheme == null && settings.colorScheme == ColorScheme.CUSTOM -> ColorScheme.default - else -> settings.colorScheme - } - setTheme(colorScheme.styleResId) - if (importedTheme != null) { - val isNightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - setTheme( - if (isNightMode) R.style.ThemeOverlay_Futon_Custom_Dark - else R.style.ThemeOverlay_Futon_Custom_Light, - ) - DynamicColors.applyToActivityIfAvailable( - this, - DynamicColorsOptions.Builder() - .setContentBasedSource(importedTheme.primaryColor) - .build(), - ) - } - if (isAmoledTheme) { - setTheme(R.style.ThemeOverlay_Futon_Amoled) + if (applyColorSchemeTheme) { + val importedTheme = settings.selectedCustomTheme.takeIf { + settings.colorScheme == ColorScheme.CUSTOM && DynamicColors.isDynamicColorAvailable() + } + val colorScheme = when { + importedTheme == null && settings.colorScheme == ColorScheme.CUSTOM -> ColorScheme.default + else -> settings.colorScheme + } + setTheme(colorScheme.styleResId) + if (importedTheme != null) { + val isNightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + setTheme( + if (isNightMode) R.style.ThemeOverlay_Futon_Custom_Dark + else R.style.ThemeOverlay_Futon_Custom_Light, + ) + DynamicColors.applyToActivityIfAvailable( + this, + DynamicColorsOptions.Builder() + .setContentBasedSource(importedTheme.primaryColor) + .build(), + ) + } + if (isAmoledTheme) { + setTheme(R.style.ThemeOverlay_Futon_Amoled) + } } putDataToExtras(intent) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) - enableEdgeToEdge() + if (applyColorSchemeTheme) { + enableEdgeToEdge() + } super.onCreate(savedInstanceState) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/ForegroundActivityHolder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/ForegroundActivityHolder.kt new file mode 100644 index 0000000000..c11f5b2b26 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/util/ForegroundActivityHolder.kt @@ -0,0 +1,31 @@ +package io.github.landwarderer.futon.core.ui.util + +import android.app.Activity +import io.github.landwarderer.futon.core.ui.DefaultActivityLifecycleCallbacks +import java.lang.ref.WeakReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tracks the currently resumed Activity, so the automatic CloudFlare solver can be started on top of + * the screen the user is looking at instead of in a new task. + */ +@Singleton +class ForegroundActivityHolder @Inject constructor() : DefaultActivityLifecycleCallbacks { + + @Volatile + private var activityRef: WeakReference? = null + + val current: Activity? + get() = activityRef?.get()?.takeUnless { it.isFinishing || it.isDestroyed } + + override fun onActivityResumed(activity: Activity) { + activityRef = WeakReference(activity) + } + + override fun onActivityPaused(activity: Activity) { + if (activityRef?.get() === activity) { + activityRef = null + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsErrorObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsErrorObserver.kt index 6da34dacfa..181e0c6320 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsErrorObserver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsErrorObserver.kt @@ -2,9 +2,12 @@ package io.github.landwarderer.futon.details.ui import com.google.android.material.snackbar.Snackbar import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.exceptions.UnsupportedSourceException import io.github.landwarderer.futon.core.exceptions.resolve.ErrorObserver import io.github.landwarderer.futon.core.exceptions.resolve.ExceptionResolver +import io.github.landwarderer.futon.core.prefs.SourceSettings +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException import io.github.landwarderer.futon.core.util.ext.getDisplayMessage import io.github.landwarderer.futon.core.util.ext.isNetworkError import io.github.landwarderer.futon.core.util.ext.isSerializable @@ -14,7 +17,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException class DetailsErrorObserver( override val activity: DetailsActivity, private val viewModel: DetailsViewModel, - resolver: ExceptionResolver?, + private val resolver: ExceptionResolver?, ) : ErrorObserver( activity.viewBinding.scrollView, null, resolver, { isResolved -> @@ -24,7 +27,24 @@ class DetailsErrorObserver( }, ) { + private var isAutoResolveAttempted = false + override suspend fun emit(value: Throwable) { + // Opening a manga is an explicit user action, so a silent captcha solve is appropriate. Only once per + // screen: if the source is still protected after that, the snackbar with the manual "Solve" follows. + val cf = value.findCloudFlareException() + if ( + cf is CloudFlareProtectedException && + resolver != null && + !isAutoResolveAttempted && + !SourceSettings(host.context, cf.source).isCaptchaAutoResolveDisabled + ) { + isAutoResolveAttempted = true + if (resolver.resolve(cf, tryAutoResolve = true)) { + viewModel.reload() + return + } + } val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) snackbar.setAnchorView(activity.viewBinding.containerBottomSheet) if (value is NotFoundException || value is UnsupportedSourceException) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/bookmarks/BookmarksViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/bookmarks/BookmarksViewModel.kt index 4ebd2ce81c..da9701534d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/bookmarks/BookmarksViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/bookmarks/BookmarksViewModel.kt @@ -51,7 +51,7 @@ class BookmarksViewModel @Inject constructor( .map { mapList(m, it) } }.withErrorHandling() .filterNotNull() - .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState)) + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState())) override suspend fun emit(value: MangaDetails?) { manga.value = value?.toManga() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt index 1e72cc156f..9042afd9c0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/related/RelatedListViewModel.kt @@ -53,11 +53,11 @@ class RelatedListViewModel @Inject constructor( ) { list, mode, error -> when { list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) - list == null -> listOf(LoadingState) + list == null -> listOf(LoadingState()) list.isEmpty() -> listOf(createEmptyState()) else -> mangaListMapper.toListModelList(list, mode) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { loadList() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt index 1ce9471ee8..4b2cbe1bdf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/list/DownloadsViewModel.kt @@ -80,8 +80,8 @@ class DownloadsViewModel @Inject constructor( val onActionDone = MutableEventFlow() val items = works.map { - it?.toUiList() ?: listOf(LoadingState) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + it?.toUiList() ?: listOf(LoadingState()) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) val hasPausedWorks = works.map { it?.any { x -> x.canResume } == true diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt index a2ba0bc24b..750ea36261 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt @@ -200,7 +200,7 @@ class ExploreViewModel @Inject constructor( private fun getLoadingStateList() = listOf( ExploreButtons(isRandomLoading.value), - LoadingState, + LoadingState(), ) private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/FavouritesCategoriesViewModel.kt index 1fe5bfee23..7e8269de11 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -45,7 +45,7 @@ class FavouritesCategoriesViewModel @Inject constructor( ) { cats, all, showAll, hasActions -> cats.toUiList(all, showAll, hasActions) }.withErrorHandling() - .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) fun deleteCategories(ids: Set) { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/select/FavoriteDialogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/select/FavoriteDialogViewModel.kt index e672a8fa5a..3a351ec94a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/select/FavoriteDialogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/categories/select/FavoriteDialogViewModel.kt @@ -47,7 +47,7 @@ class FavoriteDialogViewModel @Inject constructor( ) { categories, _, tracker -> mapList(categories, tracker) }.withErrorHandling() - .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) fun setChecked(categoryId: Long, isChecked: Boolean) { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt index 1de40532f9..090782892d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListViewModel.kt @@ -92,7 +92,7 @@ class FavouritesListViewModel @Inject constructor( isPaginationReady.set(true) }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) override fun onRefresh() { refreshTrigger.value = Any() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/filter/ui/tags/TagsCatalogViewModel.kt index b5b0de5212..fd8b60a496 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/filter/ui/tags/TagsCatalogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/filter/ui/tags/TagsCatalogViewModel.kt @@ -45,13 +45,13 @@ class TagsCatalogViewModel @AssistedInject constructor( filterProperty.map { it.selectedItems }, ) { available, cached, selected -> buildList(available, cached, selected) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) val content = combine(tags, searchQuery) { raw, query -> raw.filter { x -> x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState())) fun handleTagClick(tag: MangaTag, isChecked: Boolean) { if (isExcluded) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt index b3e05a0138..a7b080606c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt @@ -109,7 +109,7 @@ class HistoryListViewModel @Inject constructor( isPaginationReady.set(true) }.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) override fun onRefresh() = Unit diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/LoadingStateAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/LoadingStateAD.kt index 3d040df775..08d3872100 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/LoadingStateAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/LoadingStateAD.kt @@ -1,9 +1,16 @@ package io.github.landwarderer.futon.list.ui.adapter -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate -import io.github.landwarderer.futon.R +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.core.util.ext.setTextAndVisible +import io.github.landwarderer.futon.databinding.ItemLoadingStateBinding import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState -fun loadingStateAD() = adapterDelegate(R.layout.item_loading_state) { -} \ No newline at end of file +fun loadingStateAD() = adapterDelegateViewBinding( + { inflater, parent -> ItemLoadingStateBinding.inflate(inflater, parent, false) }, +) { + + bind { + binding.textViewMessage.setTextAndVisible(item.textResId) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/LoadingState.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/LoadingState.kt index 970bef75af..8776f6e517 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/LoadingState.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/LoadingState.kt @@ -1,8 +1,10 @@ package io.github.landwarderer.futon.list.ui.model -object LoadingState : ListModel { +import androidx.annotation.StringRes - override fun equals(other: Any?): Boolean = other === LoadingState +data class LoadingState( + @StringRes val textResId: Int = 0, +) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is LoadingState diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/picker/ui/manga/MangaPickerViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/picker/ui/manga/MangaPickerViewModel.kt index 828adad830..3559293d80 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/picker/ui/manga/MangaPickerViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/picker/ui/manga/MangaPickerViewModel.kt @@ -37,7 +37,7 @@ class MangaPickerViewModel @Inject constructor( override val content: StateFlow> get() = flow { emit(loadList()) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState())) override fun onRefresh() = Unit 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 c1940ad589..5ab8dfe31c 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 @@ -5,6 +5,7 @@ import androidx.annotation.CheckResult import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaAutoResolveCoordinator import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.details.data.MangaDetails import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -17,6 +18,7 @@ private const val PAGES_TRIM_THRESHOLD = 120 @ViewModelScoped class ChaptersLoader @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, + private val captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator, ) { private val chapters = LongSparseArray() @@ -40,7 +42,8 @@ class ChaptersLoader @Inject constructor( if (index == -1) return false val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return false if (mutex.withLock { newChapter.id in chapterPages }) return false - val newPages = loadChapter(newChapter.id) + // Adjacent chapters are loaded ahead of the user: never start a captcha solver for them. + val newPages = loadChapter(newChapter.id, mayStartVerification = false) if (newPages.isEmpty()) return false return mutex.withLock { if (newChapter.id in chapterPages) return@withLock false @@ -63,7 +66,7 @@ class ChaptersLoader @Inject constructor( } suspend fun loadSingleChapter(chapterId: Long, keepCurrentOnEmpty: Boolean = false): Boolean { - val pages = loadChapter(chapterId) + val pages = loadChapter(chapterId, mayStartVerification = true) if (keepCurrentOnEmpty && pages.isEmpty()) return false return mutex.withLock { chapterPages.clear() @@ -92,10 +95,16 @@ class ChaptersLoader @Inject constructor( fun snapshot() = chapterPages.toList() - private suspend fun loadChapter(chapterId: Long): List { + private suspend fun loadChapter( + chapterId: Long, + mayStartVerification: Boolean, + ): List { val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } val repo = mangaRepositoryFactory.create(chapter.source) - return repo.getPages(chapter).mapIndexed { index, page -> + val pages = captchaAutoResolveCoordinator.runWithVerification(mayStartVerification) { + repo.getPages(chapter) + } + return pages.mapIndexed { index, page -> ReaderPage(page, index, chapterId) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt index 18d203d54a..f870e39499 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt @@ -20,6 +20,7 @@ import com.davemorrissey.labs.subscaleview.ImageSource import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.scopes.ActivityRetainedScoped import io.github.landwarderer.futon.core.LocalizedAppContext +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaAutoResolveCoordinator import io.github.landwarderer.futon.core.image.BitmapDecoderCompat import io.github.landwarderer.futon.core.network.CommonHeaders import io.github.landwarderer.futon.core.network.MangaHttpClient @@ -91,6 +92,7 @@ class PageLoader @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, + private val captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator, ) { val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.IO @@ -183,7 +185,8 @@ class PageLoader @Inject constructor( suspend fun updateCache(page: MangaPage) { val progress = MutableStateFlow(PROGRESS_UNDEFINED) - loadPageImpl(page, progress, isPrefetch = false, skipCache = true) + // Runs when the reader is left, so it must never start a captcha solver. + loadPageImpl(page, progress, isPrefetch = false, skipCache = true, mayStartVerification = false) } suspend fun loadPage(page: MangaPage, force: Boolean): Uri { @@ -284,7 +287,19 @@ class PageLoader @Inject constructor( progress: MutableStateFlow, isPrefetch: Boolean, skipCache: Boolean, + mayStartVerification: Boolean = !isPrefetch, ): Uri = semaphore.withPermit { + captchaAutoResolveCoordinator.runWithVerification(mayStartVerification) { + loadPageAttempt(page, progress, isPrefetch, skipCache) + } + } + + private suspend fun loadPageAttempt( + page: MangaPage, + progress: MutableStateFlow, + isPrefetch: Boolean, + skipCache: Boolean, + ): Uri { val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" } if (!skipCache) { 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 f2474f91e9..b745520405 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 @@ -10,7 +10,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.R import io.github.landwarderer.futon.bookmarks.domain.Bookmark import io.github.landwarderer.futon.bookmarks.domain.BookmarksRepository +import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.exceptions.EmptyMangaException +import io.github.landwarderer.futon.core.exceptions.resolve.CaptchaAutoResolveCoordinator import io.github.landwarderer.futon.core.model.LocalMangaSource import io.github.landwarderer.futon.core.model.MangaHistory import io.github.landwarderer.futon.core.model.getPreferredBranch @@ -25,6 +27,7 @@ import io.github.landwarderer.futon.core.prefs.observeAsFlow import io.github.landwarderer.futon.core.prefs.observeAsStateFlow import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException import io.github.landwarderer.futon.core.util.ext.firstNotNull import io.github.landwarderer.futon.core.util.ext.requireValue import io.github.landwarderer.futon.details.data.MangaDetails @@ -68,6 +71,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -83,9 +87,11 @@ import javax.inject.Inject private const val BOUNDS_PAGE_OFFSET = 2 private const val PREFETCH_LIMIT = 10 +private const val MAX_DETAILS_VERIFICATION_RETRIES = 2L @HiltViewModel class ReaderViewModel @Inject constructor( + private val captchaAutoResolveCoordinator: CaptchaAutoResolveCoordinator, private val savedStateHandle: SavedStateHandle, private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, @@ -476,7 +482,14 @@ class ReaderViewModel @Inject constructor( var deferredDetails: MangaDetails? = null var savedPageCount = 0 try { + captchaAutoResolveCoordinator.awaitActiveResolve() detailsLoadUseCase(intent, force = false) + .retryWhen { cause, attempt -> + // Opening the reader is an explicit action: solve the captcha silently and retry. + val cf = cause.findCloudFlareException() + attempt < MAX_DETAILS_VERIFICATION_RETRIES && cf is CloudFlareProtectedException && + captchaAutoResolveCoordinator.resolveIfEnabled(cf) + } .collect { details -> loadedDetails = details if (mangaDetails.value == null) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListFragment.kt index e099d500b4..76269f1dc6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListFragment.kt @@ -11,9 +11,11 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.nav.router +import io.github.landwarderer.futon.core.prefs.SourceSettings import io.github.landwarderer.futon.core.ui.list.ListSelectionController import io.github.landwarderer.futon.core.ui.util.MenuInvalidator import io.github.landwarderer.futon.core.util.ext.addMenuProvider @@ -21,6 +23,7 @@ import io.github.landwarderer.futon.core.util.ext.getCauseUrl import io.github.landwarderer.futon.core.util.ext.isHttpUrl import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.core.util.ext.observeEvent +import io.github.landwarderer.futon.core.util.ext.viewLifecycleScope import io.github.landwarderer.futon.core.util.ext.withArgs import io.github.landwarderer.futon.databinding.FragmentListBinding import io.github.landwarderer.futon.filter.ui.FilterCoordinator @@ -43,6 +46,21 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner, View.On viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it, viewModel.source.getTitle(requireContext())) } viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() } + viewModel.onCaptchaRequired.observeEvent(viewLifecycleOwner) { e -> + if (SourceSettings(requireContext(), e.source).isCaptchaAutoResolveDisabled) { + return@observeEvent + } + viewLifecycleScope.launch { + viewModel.setCaptchaResolving(true) + try { + if (exceptionResolver.resolve(e, tryAutoResolve = true)) { + viewModel.onRetry() + } + } finally { + viewModel.setCaptchaResolving(false) + } + } + } filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListViewModel.kt index 9c793dcfcd..1402f09ce4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/remotelist/ui/RemoteListViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.model.MangaSource import io.github.landwarderer.futon.core.model.distinctById import io.github.landwarderer.futon.core.parser.MangaDataRepository @@ -12,6 +13,7 @@ import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.core.util.ext.findCloudFlareException import io.github.landwarderer.futon.core.util.ext.getCauseUrl import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.explore.data.MangaSourcesRepository @@ -67,22 +69,29 @@ open class RemoteListViewModel @Inject constructor( val isRandomLoading = MutableStateFlow(false) val onOpenManga = MutableEventFlow() val onSourceBroken = MutableEventFlow() + val onCaptchaRequired = MutableEventFlow() protected val repository = mangaRepositoryFactory.create(source) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) + private val isResolvingCaptcha = MutableStateFlow(false) private var loadingJob: Job? = null private var randomJob: Job? = null + private var initialOpenMayStartVerification = true override val content = combine( mangaList.map { it?.skipNsfwIfNeeded() }, observeListModeWithTriggers(), listError, hasNextPage, - ) { list, mode, error, hasNext -> + isResolvingCaptcha, + ) { list, mode, error, hasNext, resolvingCaptcha -> buildList(list?.size?.plus(2) ?: 2) { when { + // While the silent captcha solve is in progress, show a loading state instead of the error. + resolvingCaptcha && list.isNullOrEmpty() -> add(LoadingState(R.string.captcha_solving)) + list.isNullOrEmpty() && error != null -> add( error.toErrorState( canRetry = true, @@ -90,7 +99,7 @@ open class RemoteListViewModel @Inject constructor( ), ) - list == null -> add(LoadingState) + list == null -> add(LoadingState()) list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { mapMangaList(this, list, mode) @@ -103,7 +112,7 @@ open class RemoteListViewModel @Inject constructor( } onBuildList(this) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Lazily, listOf(LoadingState())) init { filterCoordinator.observe() @@ -144,14 +153,27 @@ open class RemoteListViewModel @Inject constructor( loadingJob?.let { if (it.isActive) return it } + // Only opening the catalog is an explicit action which may start the silent captcha solver; + // paging, refreshing and filter changes fall back to the manual "Solve" action. + val mayStartVerification = initialOpenMayStartVerification && !append + initialOpenMayStartVerification = false return launchLoadingJob(Dispatchers.IO) { try { listError.value = null - val list = repository.getList( - offset = if (append) mangaList.value.sizeOrZero() else 0, - order = filterState.sortOrder, - filter = filterState.listFilter, - ) + val list = try { + repository.getList( + offset = if (append) mangaList.value.sizeOrZero() else 0, + order = filterState.sortOrder, + filter = filterState.listFilter, + ) + } catch (e: Exception) { + val cf = e.findCloudFlareException() + if (cf is CloudFlareProtectedException && mayStartVerification && !isResolvingCaptcha.value) { + // The Fragment owns the resolve (it needs an ExceptionResolver); a success triggers onRetry(). + onCaptchaRequired.call(cf) + } + throw e + } val prevList = mangaList.value.orEmpty() if (!append) { mangaList.value = list.distinctById() @@ -176,6 +198,11 @@ open class RemoteListViewModel @Inject constructor( }.also { loadingJob = it } } + /** Flipped by the Fragment around the silent captcha resolve. */ + fun setCaptchaResolving(resolving: Boolean) { + isResolvingCaptcha.value = resolving + } + protected open fun createEmptyState(canResetFilter: Boolean) = EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index dcbac6e044..6014b7c1de 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -80,7 +80,7 @@ class ScrobblingSelectorViewModel @Inject constructor( }, ) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) val selectedItemId = MutableStateFlow(NO_ID) val onClose = MutableEventFlow() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt index e8496f1814..8c5f0e1600 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/multi/SearchViewModel.kt @@ -89,7 +89,7 @@ class SearchViewModel @Inject constructor( when { filteredList.isEmpty() -> listOf( when { - loading -> LoadingState + loading -> LoadingState() else -> EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, @@ -103,7 +103,7 @@ class SearchViewModel @Inject constructor( includeDisabled -> filteredList else -> filteredList + ButtonFooter(R.string.search_disabled_sources) } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { doSearch() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt index f1d9708b1a..2a41a196d2 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsFragment.kt @@ -59,6 +59,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc isVisible = authProvider != null } findPreference(SourceSettings.KEY_SLOWDOWN)?.isVisible = isValidSource + // The per-source opt-out only matters while automatic solving is enabled globally + findPreference(SourceSettings.KEY_NO_AUTO_CAPTCHA)?.isEnabled = settings.isCaptchaAutoSolveEnabled } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt index 1a95d29788..67a2d626d1 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -67,7 +67,7 @@ class SourcesCatalogViewModel @Inject constructor( mihonExtensionManager.installedExtensions, ) { q, f, _, _ -> buildSourcesList(f, q) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { repository.clearNewSourcesBadge() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsViewModel.kt index 24e6f97912..0993b5ec90 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsViewModel.kt @@ -80,7 +80,7 @@ class SuggestionsViewModel @Inject constructor( loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) override fun onRefresh() = Unit diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt index a0ae643e7c..a8c3a21f8d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/suggestions/ui/SuggestionsWorker.kt @@ -319,7 +319,7 @@ class SuggestionsWorker @AssistedInject constructor( list.take(MAX_SOURCE_RESULTS) }.onFailure { e -> if (e is CloudFlareException) { - captchaHandler.handle(e) + captchaHandler.handle(e, tryAutoResolve = false) } e.printStackTraceDebug("SuggestionsWorker::getList") }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt index 6976bc4911..8bdf3a5d16 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/feed/FeedViewModel.kt @@ -93,7 +93,7 @@ class FeedViewModel @Inject constructor( result as List }.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/updates/UpdatesViewModel.kt index 7dfc5231fa..d53965144c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/ui/updates/UpdatesViewModel.kt @@ -75,7 +75,7 @@ class UpdatesViewModel @Inject constructor( loadingCounter.decrement() }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt index b206cb1aee..0c109f59d7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/tracker/work/TrackWorker.kt @@ -201,7 +201,7 @@ class TrackWorker @AssistedInject constructor( is MangaUpdates.Failure -> { val e = it.error?.findCloudFlareException() if (e != null) { - captchaHandler.handle(e) + captchaHandler.handle(e, tryAutoResolve = false) } } diff --git a/app/src/main/res/layout/item_loading_state.xml b/app/src/main/res/layout/item_loading_state.xml index ab836f1674..8ef2ac3e3c 100644 --- a/app/src/main/res/layout/item_loading_state.xml +++ b/app/src/main/res/layout/item_loading_state.xml @@ -1,14 +1,34 @@ - + android:gravity="center_horizontal" + android:orientation="vertical"> + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b378ded572..62c939bafe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,6 +168,7 @@ Silent CAPTCHA required Solve + Solving captcha automatically… Clear cookies All cookies were removed Clear feed @@ -860,6 +861,10 @@ Simple Global search Disable captcha notifications + Disable automatic CAPTCHA solving + Don\'t try to solve CAPTCHA silently. You will be asked to solve it manually instead. + Solve CAPTCHAs automatically (experimental) + Try to pass Cloudflare checks silently when you open a catalog, a manga or a chapter. It can still be turned off for a single source in its settings You will not receive notifications about solving CAPTCHA for this source but this can lead to breaking background operations (checking for new chapters, obtaining recommendations, etc) Vol %1$s Chapter %2$s Chapter %s diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7fcd3af316..a99d399fea 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -79,6 +79,22 @@ + + + + + + + + + + +