diff --git a/app/build.gradle b/app/build.gradle index 4d56eae21c..a1eb38d8cc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -219,6 +219,7 @@ dependencies { implementation libs.coil.gif implementation libs.coil.svg implementation libs.avif.decoder + implementation libs.jxl.coder implementation libs.ssiv implementation libs.disk.lru.cache implementation libs.markwon diff --git a/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ProgressUpdateRaceTest.kt b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ProgressUpdateRaceTest.kt index b0af2dfa56..a4a173e88b 100644 --- a/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ProgressUpdateRaceTest.kt +++ b/app/src/androidTest/kotlin/io/github/landwarderer/futon/reader/ProgressUpdateRaceTest.kt @@ -74,6 +74,19 @@ class ProgressUpdateRaceTest { assertEquals(result, saved.percent) } + @Test + fun recalculationKeepsChaptersCountForSmartResume() = runBlocking { + val fixture = fixture() + val dao = database.getHistoryDao() + // read while the source had 29 chapters; chapter 30 has been released since + dao.update(dao.find(fixture.manga.id)!!.copy(chaptersCount = 29)) + val job = async(Dispatchers.IO) { recalculate(fixture.manga) } + withTimeout(30_000) { fixture.started.await() } + fixture.release() + withTimeout(30_000) { job.await() } + assertEquals(29, dao.find(fixture.manga.id)!!.chaptersCount) + } + @Test fun historyRemovedDuringRecalculationStaysRemoved() = runBlocking { val fixture = fixture() 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 57e1aa03b2..24b8dfae13 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" /> + + + + + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesSearchOptions.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesSearchOptions.kt new file mode 100644 index 0000000000..e4dafba8a6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesSearchOptions.kt @@ -0,0 +1,71 @@ +package io.github.landwarderer.futon.alternatives.domain + +import org.koitharu.kotatsu.parsers.model.Manga + +data class AlternativesSearchOptions( + val query: String, + val sourceScope: AlternativeSourceScope = AlternativeSourceScope.Enabled, + val sameLanguageOnly: Boolean = false, + val sameContentTypeOnly: Boolean = false, + val hideNoChapters: Boolean = false, + val sortOrder: AlternativeSortOrder = AlternativeSortOrder.BEST_MATCH, +) { + + fun isDefaultFor(title: String, defaultScope: AlternativeSourceScope): Boolean = + this == AlternativesSearchOptions(query = title, sourceScope = defaultScope) +} + +/** Which sources an alternatives search or a source replacement runs across. */ +sealed interface AlternativeSourceScope { + + data object Enabled : AlternativeSourceScope + + /** The sources the user has not enabled - where an alternative is most often hiding. */ + data object Disabled : AlternativeSourceScope + + /** Any saved preset, not only the active one. */ + data class Preset(val id: Long) : AlternativeSourceScope +} + +enum class AlternativeSortOrder { + BEST_MATCH, + MOST_CHAPTERS, + CLOSEST_CHAPTER_COUNT, + SOURCE_PRIORITY, +} + +sealed interface AlternativeSearchEvent { + + data class Result(val manga: Manga) : AlternativeSearchEvent + + data class Progress( + val completedSources: Int, + val totalSources: Int, + ) : AlternativeSearchEvent +} + +data class AlternativeSearchStatus( + val completedSources: Int = 0, + val totalSources: Int = 0, + val resultsCount: Int = 0, + val isRunning: Boolean = false, +) + +data class AlternativeSourceScopeOptions( + val defaultScope: AlternativeSourceScope, + /** Presets that have at least one candidate source, in the user's preset order. */ + val presets: List = emptyList(), + /** False when every candidate is already enabled, or every source is enabled. */ + val hasDisabledSources: Boolean = false, +) { + + data class PresetChoice(val id: Long, val title: String) + + fun isAvailable(scope: AlternativeSourceScope): Boolean = when (scope) { + AlternativeSourceScope.Enabled -> true + AlternativeSourceScope.Disabled -> hasDisabledSources + is AlternativeSourceScope.Preset -> presets.any { it.id == scope.id } + } + + fun presetTitle(id: Long): String? = presets.firstOrNull { it.id == id }?.title +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesUseCase.kt index 464b96d351..32b0aa528b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AlternativesUseCase.kt @@ -1,5 +1,17 @@ package io.github.landwarderer.futon.alternatives.domain +import io.github.landwarderer.futon.core.model.contentTypeCompat +import io.github.landwarderer.futon.core.model.isNsfw +import io.github.landwarderer.futon.core.model.primaryLanguage +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.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository +import io.github.landwarderer.futon.search.domain.SearchKind +import io.github.landwarderer.futon.search.domain.SearchV2Helper +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.emptyFlow @@ -7,48 +19,218 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import io.github.landwarderer.futon.core.model.isNovelSource -import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.util.ext.toLocale -import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import io.github.landwarderer.futon.search.domain.SearchKind -import io.github.landwarderer.futon.search.domain.SearchV2Helper import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject -private const val MAX_PARALLELISM = 4 +private const val MAX_PARALLELISM = 8 +private const val MAX_PER_SOURCE_PARALLELISM = 2 class AlternativesUseCase @Inject constructor( private val sourcesRepository: MangaSourcesRepository, private val searchHelperFactory: SearchV2Helper.Factory, private val mangaRepositoryFactory: MangaRepository.Factory, + private val settings: AppSettings, + private val presetsRepository: SourcePresetsRepository, ) { + private val requestSemaphore = Semaphore(MAX_PARALLELISM) + private val webViewSemaphore = Semaphore(1) + private val sourceSemaphores = HashMap() + private val webViewSources = HashMap() + + /** Searches the enabled or the disabled sources, in any language. */ suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow { - val sources = getSources(manga.source, throughDisabledSources) + val sources = getCandidateSources( + ref = manga.source, + sourceScope = if (throughDisabledSources) AlternativeSourceScope.Disabled else AlternativeSourceScope.Enabled, + sameLanguageOnly = false, + ) + return search(manga, sources, manga.title, loadDetails = true) + } + + suspend operator fun invoke( + manga: Manga, + sourceScope: AlternativeSourceScope, + query: String = manga.title, + loadDetails: Boolean = true, + ): Flow { + val sources = getCandidateSources( + ref = manga.source, + sourceScope = sourceScope, + sameLanguageOnly = true, + ) + return search(manga, sources, query, loadDetails) + } + + /** + * @param loadDetails fetch each candidate's details page. Needed for chapter counts, but a caller + * that only wants what the search already returned - a cover, say - should pass `false`: it is one + * extra network round trip per result. + */ + suspend operator fun invoke( + manga: Manga, + options: AlternativesSearchOptions, + loadDetails: Boolean = true, + ): Flow { + val query = options.query.trim() + if (query.isEmpty()) return emptyFlow() + val sources = getCandidateSources( + ref = manga.source, + sourceScope = options.sourceScope, + sameLanguageOnly = options.sameLanguageOnly, + sameContentTypeOnly = options.sameContentTypeOnly, + ) + if (sources.isEmpty()) return emptyFlow() + return channelFlow { + val completedSources = AtomicInteger() + send(AlternativeSearchEvent.Progress(0, sources.size)) + val workerCount = minOf(MAX_PARALLELISM, sources.size) + repeat(workerCount) { workerIndex -> + launch { + for (sourceIndex in workerIndex until sources.size step workerCount) { + val source = sources[sourceIndex] + try { + // Finish this source's candidate details before this worker starts another + // source, so chapter requests are not starved behind every source search. + coroutineScope { + search( + manga = manga, + sources = listOf(source), + query = query, + loadDetails = false, + ).collect { candidate -> + launch { + send( + AlternativeSearchEvent.Result( + if (loadDetails) loadDetails(candidate) else candidate, + ), + ) + } + } + } + } finally { + send( + AlternativeSearchEvent.Progress( + completedSources = completedSources.incrementAndGet(), + totalSources = sources.size, + ), + ) + } + } + } + } + } + } + + suspend fun getCandidateSources( + ref: MangaSource, + sourceScope: AlternativeSourceScope, + sameLanguageOnly: Boolean = true, + sameContentTypeOnly: Boolean = false, + ): List { + val sources = when (sourceScope) { + is AlternativeSourceScope.Preset -> presetsRepository.getById(sourceScope.id) + ?.let { getPresetSources(it) } + .orEmpty() + + AlternativeSourceScope.Enabled -> sourcesRepository.getEnabledSources() + AlternativeSourceScope.Disabled -> sourcesRepository.getDisabledSources().toList() + } + val skipNsfw = settings.isNsfwContentDisabled + return sources.asSequence() + .distinctBy(MangaSource::name) + .filter { it.name != ref.name } + .filter { it.isNovelSource == ref.isNovelSource } + .filterNot { skipNsfw && it.isNsfw() } + .filter { !sameLanguageOnly || it.hasSameLanguageAs(ref) } + .filter { !sameContentTypeOnly || it.hasSameContentTypeAs(ref) } + .sortedWith(compareByDescending { it.priority(ref) }.thenBy(MangaSource::name)) + .toList() + } + + suspend fun getSourceScopeOptions( + ref: MangaSource, + sameLanguageOnly: Boolean = false, + ): AlternativeSourceScopeOptions { + val skipNsfw = settings.isNsfwContentDisabled + fun MangaSource.isCandidate() = name != ref.name && + isNovelSource == ref.isNovelSource && + (!skipNsfw || !isNsfw()) && + (!sameLanguageOnly || hasSameLanguageAs(ref)) + + // A preset with nothing searchable for this manga would only ever produce an empty screen. + val presets = presetsRepository.getAll() + .filter { preset -> getPresetSources(preset).any { it.isCandidate() } } + .map { AlternativeSourceScopeOptions.PresetChoice(it.id, it.title) } + val activePresetId = settings.activeSourcePresetId + return AlternativeSourceScopeOptions( + // The active preset when it has candidates, otherwise the enabled sources. + defaultScope = if (presets.any { it.id == activePresetId }) { + AlternativeSourceScope.Preset(activePresetId) + } else { + AlternativeSourceScope.Enabled + }, + presets = presets, + hasDisabledSources = sourcesRepository.getDisabledSources().any { it.isCandidate() }, + ) + } + + suspend fun searchSource( + manga: Manga, + source: MangaSource, + query: String = manga.title, + loadDetails: Boolean = true, + onFailure: (() -> Unit)? = null, + ): Flow = if (source.name == manga.source.name) { + emptyFlow() + } else { + search( + manga = manga, + sources = listOf(source), + query = query, + loadDetails = loadDetails, + onSourceFailure = { onFailure?.invoke() }, + ) + } + + fun isWebViewSource(source: MangaSource): Boolean = source.usesWebView() + + private fun search( + manga: Manga, + sources: List, + query: String, + loadDetails: Boolean, + onSourceFailure: (MangaSource) -> Unit = {}, + ): Flow { if (sources.isEmpty()) { return emptyFlow() } - val semaphore = Semaphore(MAX_PARALLELISM) return channelFlow { for (source in sources) { launch { val searchHelper = searchHelperFactory.create(source) - val list = runCatchingCancellable { - semaphore.withPermit { - searchHelper(manga.title, SearchKind.TITLE)?.manga + val searchResult = runCatchingCancellable { + withSourcePermit(source) { + searchHelper(query, SearchKind.TITLE)?.manga } - }.getOrNull() - list?.forEach { m -> + } + if (searchResult.isFailure) { + onSourceFailure(source) + } + searchResult.getOrNull()?.forEach { m -> if (m.id != manga.id) { - launch { - val details = runCatchingCancellable { - mangaRepositoryFactory.create(m.source).getDetails(m) - }.getOrDefault(m) - send(details) + if (loadDetails) { + launch { + send(loadDetails(m)) + } + } else { + send(m) } } } @@ -57,24 +239,70 @@ class AlternativesUseCase @Inject constructor( } } - private suspend fun getSources(ref: MangaSource, disabled: Boolean): List = if (disabled) { - sourcesRepository.getDisabledSources() - } else { - sourcesRepository.getEnabledSources() - }.filter { it.isNovelSource == ref.isNovelSource } - .sortedByDescending { it.priority(ref) } + private suspend fun loadDetails(manga: Manga): Manga = runCatchingCancellable { + withSourcePermit(manga.source) { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + } + }.getOrDefault(manga) + + private suspend fun withSourcePermit(source: MangaSource, block: suspend () -> T): T { + return source.requestSemaphore().withPermit { + if (source.usesWebView()) { + webViewSemaphore.withPermit { + requestSemaphore.withPermit { block() } + } + } else { + requestSemaphore.withPermit { block() } + } + } + } + + private fun MangaSource.requestSemaphore(): Semaphore = synchronized(sourceSemaphores) { + sourceSemaphores.getOrPut(name) { + Semaphore(if (usesWebView()) 1 else MAX_PER_SOURCE_PARALLELISM) + } + } + + /** Sources that load through a WebView cannot run in parallel without starving each other. */ + private fun MangaSource.usesWebView(): Boolean = synchronized(webViewSources) { + webViewSources.getOrPut(name) { + val repository = mangaRepositoryFactory.create(this) + (repository as? ParserMangaRepository) + ?.getConfigKeys() + ?.filterIsInstance() + ?.any { it.defaultValue } == true + } + } + + private suspend fun getPresetSources(preset: SourcePreset): List { + return sourcesRepository.getPresetSources(preset) + } + + private fun MangaSource.hasSameLanguageAs(ref: MangaSource): Boolean { + val refLanguage = ref.primaryLanguage ?: return true + // Multilingual sources (and Mihon "all" extensions) may well carry the title in that language. + val language = primaryLanguage ?: return true + return language == refLanguage + } + + private fun MangaSource.hasSameContentTypeAs(ref: MangaSource): Boolean { + val type = contentTypeCompat ?: return false + return type == ref.contentTypeCompat + } private fun MangaSource.priority(ref: MangaSource): Int { var res = 0 - if (this is MangaParserSource && ref is MangaParserSource) { - if (locale == ref.locale) { + val language = primaryLanguage + if (language != null) { + if (language == ref.primaryLanguage) { res += 4 - } else if (locale.toLocale() == Locale.getDefault()) { + } else if (language == Locale.getDefault().language) { res += 2 } - if (contentType == ref.contentType) { - res++ - } + } + val type = contentTypeCompat + if (type != null && type == ref.contentTypeCompat) { + res++ } return res } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AutoFixUseCase.kt index cc053f11e7..7ac1a74284 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AutoFixUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/AutoFixUseCase.kt @@ -15,7 +15,6 @@ import io.github.landwarderer.futon.core.model.chaptersCount import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.parser.MangaRepository -import io.github.landwarderer.futon.core.util.ext.concat import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.concurrent.TimeUnit @@ -36,8 +35,11 @@ class AutoFixUseCase @Inject constructor( if (seed.isHealthy()) { return seed to null // no fix required } - val replacement = alternativesUseCase(seed, throughDisabledSources = false) - .concat(alternativesUseCase(seed, throughDisabledSources = true)) + val sourceScope = alternativesUseCase.getSourceScopeOptions( + ref = seed.source, + sameLanguageOnly = true, + ).defaultScope + val replacement = alternativesUseCase(seed, sourceScope = sourceScope) .filter { it.isHealthy() } .runningFold(null) { best, candidate -> if (best == null || best < candidate) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementMatching.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementMatching.kt new file mode 100644 index 0000000000..b118e29791 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementMatching.kt @@ -0,0 +1,58 @@ +package io.github.landwarderer.futon.alternatives.domain + +import io.github.landwarderer.futon.core.model.chaptersCount +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.levenshteinDistance +import java.util.Locale +import kotlin.math.roundToInt + +/** The pure scoring rules behind [SourceReplacementPlannerUseCase]. */ +internal object SourceReplacementMatching { + + /** Best similarity, 0..1, between any title (main or alternative) of [candidate] and of [reference]. */ + fun titleSimilarity(candidate: Manga, reference: Manga): Float { + val candidateTitles = (listOf(candidate.title) + candidate.altTitles).filter(String::isNotBlank) + val referenceTitles = (listOf(reference.title) + reference.altTitles).filter(String::isNotBlank) + return candidateTitles.maxOfOrNull { title -> + referenceTitles.maxOfOrNull { ref -> normalizedSimilarity(title, ref) } ?: 0f + } ?: 0f + } + + fun normalizedSimilarity(first: String, second: String): Float { + val a = first.trim().lowercase(Locale.ROOT) + val b = second.trim().lowercase(Locale.ROOT) + val maxLength = maxOf(a.length, b.length) + return if (maxLength == 0) 1f else 1f - a.levenshteinDistance(b).toFloat() / maxLength + } + + /** True when a candidate with [similarity]/[chapters] beats the current best one. */ + fun isBetter(similarity: Float, chapters: Int, bestSimilarity: Float, bestChapters: Int): Boolean { + return similarity > bestSimilarity || (similarity == bestSimilarity && chapters > bestChapters) + } + + fun isBetter(candidate: Manga, candidateSimilarity: Float, best: Manga, bestSimilarity: Float): Boolean = + isBetter(candidateSimilarity, candidate.chaptersCount(), bestSimilarity, best.chaptersCount()) + + /** + * Up to [limit] titles spread evenly across the alphabet, so a large library is compared on a sample + * that is not biased towards whatever was read last. + */ + fun representativeSample(manga: List, limit: Int): List { + if (manga.size <= limit) { + return manga + } + if (limit <= 1) { + return manga.take(limit) + } + val sorted = manga.sortedBy { it.title.lowercase(Locale.ROOT) } + return List(limit) { index -> + val position = index.toDouble() * sorted.lastIndex / (limit - 1) + sorted[position.roundToInt()] + }.distinctBy(Manga::id) + } + + /** Sources with the most matches first, then the closest titles. */ + val planComparator: Comparator = + compareByDescending { it.matchedCount } + .thenByDescending(SourceReplacementPlan::averageTitleSimilarity) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementPlannerUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementPlannerUseCase.kt new file mode 100644 index 0000000000..96d5c41e3d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/domain/SourceReplacementPlannerUseCase.kt @@ -0,0 +1,244 @@ +package io.github.landwarderer.futon.alternatives.domain + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +private const val MAX_COMPARISON_SAMPLE = 25 +private const val MAX_COMPARISON_PARALLELISM_PER_SOURCE = 2 +private const val MAX_COMPARISON_TIMEOUTS_PER_SOURCE = 2 +private const val COMPARISON_REQUEST_TIMEOUT_MS = 15_000L +private const val WEBVIEW_COMPARISON_REQUEST_TIMEOUT_MS = 30_000L + +/** + * Works out which sources can take over a set of titles: it searches a representative sample of the + * titles on every candidate source and ranks the sources by how many of them they have. + */ +class SourceReplacementPlannerUseCase @Inject constructor( + private val alternativesUseCase: AlternativesUseCase, +) { + + suspend operator fun invoke( + manga: List, + sourceScope: AlternativeSourceScope, + excludedSourceNames: Set = emptySet(), + onProgress: (SourceComparisonProgress) -> Unit = {}, + onSourceFailed: (MangaSource) -> Unit = {}, + ): List = coroutineScope { + val seeds = SourceReplacementMatching.representativeSample(manga.distinctBy(Manga::id), MAX_COMPARISON_SAMPLE) + if (seeds.isEmpty()) { + return@coroutineScope emptyList() + } + val sources = alternativesUseCase.getCandidateSources( + ref = seeds.first().source, + sourceScope = sourceScope, + sameLanguageOnly = true, + ).filter { it.name !in excludedSourceNames } + val totalChecks = sources.size * seeds.size + val progressLock = Any() + var completedChecks = 0 + var completedSources = 0 + fun reportProgress(titleCompleted: Boolean = false, sourceCompleted: Boolean = false) { + synchronized(progressLock) { + if (titleCompleted) completedChecks++ + if (sourceCompleted) completedSources++ + onProgress( + SourceComparisonProgress( + completedChecks = completedChecks, + totalChecks = totalChecks, + remainingSources = sources.size - completedSources, + totalSources = sources.size, + sampleSize = seeds.size, + ), + ) + } + } + reportProgress() + val plans = sources.map { source -> + async { + val comparison = compareSource(source, seeds) { + reportProgress(titleCompleted = true) + } + if (comparison.failed) { + // The very first request already failed: the source is down, blocked or needs a captcha. + synchronized(progressLock) { + onSourceFailed(source) + } + null + } else { + SourceReplacementPlan( + source = source, + matches = comparison.matches.mapValues { it.value.manga }, + total = seeds.size, + averageTitleSimilarity = comparison.matches.values + .map(ScoredMatch::titleSimilarity) + .takeIf(List::isNotEmpty) + ?.average() + ?.toFloat() + ?: 0f, + ) + }.also { reportProgress(sourceCompleted = true) } + } + }.awaitAll().filterNotNull() + plans.filter { it.matchedCount > 0 }.sortedWith(SourceReplacementMatching.planComparator) + } + + /** Finds the best match for each of [manga] on [source], skipping the ones in [knownMatches]. */ + suspend fun resolve( + manga: List, + source: MangaSource, + knownMatches: Map = emptyMap(), + onProgress: (completed: Int, total: Int) -> Unit = { _, _ -> }, + ): Map = coroutineScope { + val seeds = manga.distinctBy(Manga::id) + val seedIds = seeds.mapTo(HashSet(), Manga::id) + val result = knownMatches.filterKeys(seedIds::contains).toMutableMap() + val completed = AtomicInteger(result.size) + onProgress(completed.get(), seeds.size) + val resolved = seeds.filterNot { it.id in result }.map { seed -> + async { + findMatch(seed, source).also { + onProgress(completed.incrementAndGet(), seeds.size) + } + } + }.awaitAll() + resolved.filterNotNull().forEach { match -> result[match.seedId] = match.manga } + result + } + + private suspend fun compareSource( + source: MangaSource, + seeds: List, + onTitleChecked: () -> Unit, + ): SourceComparisonResult { + val isWebView = alternativesUseCase.isWebViewSource(source) + val parallelism = if (isWebView) 1 else MAX_COMPARISON_PARALLELISM_PER_SOURCE + val timeout = if (isWebView) WEBVIEW_COMPARISON_REQUEST_TIMEOUT_MS else COMPARISON_REQUEST_TIMEOUT_MS + val matches = HashMap() + val initialOutcome = compareTitle(source, seeds.first(), timeout) + onTitleChecked() + initialOutcome.match?.let { match -> matches[match.seedId] = match } + if (initialOutcome.failed) { + repeat(seeds.size - 1) { onTitleChecked() } + return SourceComparisonResult(matches = matches, failed = true) + } + var processed = 1 + var timeouts = 0 + for (batch in seeds.drop(1).chunked(parallelism)) { + if (timeouts >= MAX_COMPARISON_TIMEOUTS_PER_SOURCE) { + break + } + val outcomes = coroutineScope { + batch.map { seed -> + async { compareTitle(source, seed, timeout) } + }.awaitAll() + } + processed += batch.size + outcomes.forEach { outcome -> + onTitleChecked() + if (outcome.timedOut) { + timeouts++ + } + outcome.match?.let { match -> matches[match.seedId] = match } + } + } + repeat(seeds.size - processed) { onTitleChecked() } + return SourceComparisonResult(matches = matches, failed = false) + } + + private suspend fun compareTitle( + source: MangaSource, + seed: Manga, + timeout: Long, + ): ComparisonOutcome { + val requestFailed = AtomicBoolean(false) + return withTimeoutOrNull(timeout) { + ComparisonOutcome( + match = findMatch( + seed = seed, + source = source, + loadDetails = false, + onFailure = { requestFailed.set(true) }, + ), + timedOut = false, + failed = requestFailed.get(), + ) + } ?: ComparisonOutcome(match = null, timedOut = true, failed = true) + } + + private suspend fun findMatch( + seed: Manga, + source: MangaSource, + loadDetails: Boolean = true, + onFailure: (() -> Unit)? = null, + ): ScoredMatch? { + var best: ScoredMatch? = null + alternativesUseCase.searchSource( + manga = seed, + source = source, + loadDetails = loadDetails, + onFailure = onFailure, + ).collect { candidate -> + val scored = ScoredMatch( + seedId = seed.id, + manga = candidate, + titleSimilarity = SourceReplacementMatching.titleSimilarity(candidate, seed), + ) + val current = best + if (current == null || SourceReplacementMatching.isBetter( + candidate = scored.manga, + candidateSimilarity = scored.titleSimilarity, + best = current.manga, + bestSimilarity = current.titleSimilarity, + ) + ) { + best = scored + } + } + return best + } + + private data class ScoredMatch( + val seedId: Long, + val manga: Manga, + val titleSimilarity: Float, + ) + + private data class ComparisonOutcome( + val match: ScoredMatch?, + val timedOut: Boolean, + val failed: Boolean, + ) + + private data class SourceComparisonResult( + val matches: Map, + val failed: Boolean, + ) +} + +data class SourceReplacementPlan( + val source: MangaSource, + val matches: Map, + val total: Int, + val averageTitleSimilarity: Float, +) { + val matchedCount: Int + get() = matches.size + + val matchRate: Float + get() = if (total == 0) 0f else matchedCount.toFloat() / total +} + +data class SourceComparisonProgress( + val completedChecks: Int, + val totalChecks: Int, + val remainingSources: Int, + val totalSources: Int, + val sampleSize: Int, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativeAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativeAD.kt index 8d478c9396..69983e82fa 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativeAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativeAD.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.alternatives.ui import android.text.style.ForegroundColorSpan +import androidx.appcompat.widget.TooltipCompat import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.text.inSpans @@ -49,6 +50,14 @@ fun alternativeAD( bind { payloads -> binding.textViewTitle.text = item.mangaModel.title + val migrationTooltip = when { + !item.isMigrationEnabled -> context.getString(R.string.migration_unavailable_search_in_progress) + item.chaptersCount == 0 -> context.getString(R.string.migration_unavailable_no_chapters) + else -> null + } + binding.buttonMigrate.isEnabled = migrationTooltip == null + TooltipCompat.setTooltipText(binding.buttonMigrate, migrationTooltip) + binding.buttonMigrate.contentDescription = migrationTooltip ?: context.getString(R.string.migrate) with(binding.iconsView) { clearIcons() if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesActivity.kt index f73333623a..02f567973b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesActivity.kt @@ -1,22 +1,32 @@ package io.github.landwarderer.futon.alternatives.ui import android.os.Bundle +import android.view.Menu import android.view.View import android.widget.Toast import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil3.ImageLoader +import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.domain.AlternativeSearchStatus +import io.github.landwarderer.futon.alternatives.domain.AlternativeSortOrder import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.model.languageCode import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.ui.BaseActivity import io.github.landwarderer.futon.core.ui.BaseListAdapter import io.github.landwarderer.futon.core.ui.dialog.buildAlertDialog import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener +import io.github.landwarderer.futon.core.ui.util.OptionsMenuBadgeHelper +import io.github.landwarderer.futon.core.ui.widgets.ChipsView import io.github.landwarderer.futon.core.util.ext.consumeAllSystemBarsInsets +import io.github.landwarderer.futon.core.util.ext.getQuantityStringSafe 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.systemBarsInsets @@ -24,37 +34,45 @@ import io.github.landwarderer.futon.databinding.ActivityAlternativesBinding import io.github.landwarderer.futon.list.ui.adapter.ListItemType import io.github.landwarderer.futon.list.ui.adapter.ListStateHolderListener import io.github.landwarderer.futon.list.ui.adapter.TypedListSpacingDecoration -import io.github.landwarderer.futon.list.ui.adapter.buttonFooterAD import io.github.landwarderer.futon.list.ui.adapter.emptyStateListAD import io.github.landwarderer.futon.list.ui.adapter.loadingFooterAD import io.github.landwarderer.futon.list.ui.adapter.loadingStateAD import io.github.landwarderer.futon.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject +import androidx.appcompat.R as appcompatR @AndroidEntryPoint class AlternativesActivity : BaseActivity(), ListStateHolderListener, - OnListItemClickListener { + OnListItemClickListener, + ChipsView.OnChipClickListener, + ChipsView.OnChipCloseClickListener { @Inject lateinit var coil: ImageLoader private val viewModel by viewModels() + private lateinit var filterBadge: OptionsMenuBadgeHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAlternativesBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - subtitle = viewModel.manga.title - } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + viewBinding.planningProgressContainer.isVisible = false + viewBinding.summaryContainer.isVisible = false + viewBinding.recyclerView.isVisible = true + supportActionBar?.subtitle = buildReferenceSubtitle(MangaAlternativeReference(viewModel.manga)) + addMenuProvider(AlternativesMenuProvider(this, viewModel)) + filterBadge = OptionsMenuBadgeHelper(viewBinding.toolbar, R.id.action_alternatives_filter) + viewBinding.chipsSummary.onChipClickListener = this + viewBinding.chipsSummary.onChipCloseClickListener = this + val listAdapter = BaseListAdapter() .addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this)) .addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null)) .addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) .addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - .addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this)) with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false)) @@ -63,6 +81,22 @@ class AlternativesActivity : BaseActivity(), viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.list.observe(this, listAdapter) + viewModel.options.observe(this) { + updateSummaryChips() + viewBinding.toolbar.post { filterBadge.setBadgeVisible(viewModel.hasCustomOptions()) } + invalidateMenu() + } + viewModel.scopeOptions.observe(this) { + updateSummaryChips() + invalidateMenu() + } + viewModel.searchStatus.observe(this) { + updateSearchProgress(it) + updateSummaryChips() + } + viewModel.referenceManga.observe(this) { + supportActionBar?.subtitle = buildReferenceSubtitle(it) + } viewModel.onMigrated.observeEvent(this) { Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() router.openDetails(it) @@ -70,10 +104,7 @@ class AlternativesActivity : BaseActivity(), } } - override fun onApplyWindowInsets( - v: View, - insets: WindowInsetsCompat - ): WindowInsetsCompat { + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { val barsInsets = insets.systemBarsInsets viewBinding.recyclerView.updatePadding( left = barsInsets.left, @@ -90,8 +121,10 @@ class AlternativesActivity : BaseActivity(), override fun onItemClick(item: MangaAlternativeModel, view: View) { when (view.id) { - R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title) - R.id.button_migrate -> confirmMigration(item.manga) + R.id.chip_source -> router.openSearch(item.manga.source, viewModel.options.value.query) + R.id.button_migrate -> if (item.isMigrationEnabled && item.chaptersCount > 0) { + confirmMigration(item.manga) + } else -> router.openDetails(item.manga) } } @@ -100,7 +133,61 @@ class AlternativesActivity : BaseActivity(), override fun onEmptyActionClick() = Unit - override fun onFooterButtonClick() = viewModel.continueSearch() + override fun onChipClick(chip: Chip, data: Any?) { + when (data as? SummaryChip) { + SummaryChip.QUERY -> viewBinding.toolbar.menu.findItem(R.id.action_search)?.expandActionView() + SummaryChip.SOURCE_SCOPE -> showSourceScopeMenu(chip) + SummaryChip.SORT -> showSortMenu(chip) + SummaryChip.RESET -> viewModel.resetOptions() + SummaryChip.STATUS, null -> Unit + else -> viewBinding.toolbar.menu.performIdentifierAction(R.id.action_alternatives_filter, 0) + } + } + + private fun showSourceScopeMenu(anchor: View) { + PopupMenu(this, anchor).apply { + val scopeOptions = viewModel.scopeOptions.value + SourceScopeMenu.populate(menu, POPUP_GROUP, scopeOptions, viewModel.options.value.sourceScope) + setOnMenuItemClickListener { item -> + val scope = SourceScopeMenu.scopeFor(item.itemId, scopeOptions) + ?: return@setOnMenuItemClickListener false + viewModel.setSourceScope(scope) + true + } + show() + } + } + + private fun showSortMenu(anchor: View) { + PopupMenu(this, anchor).apply { + AlternativeSortOrder.entries.forEachIndexed { index, sortOrder -> + menu.add(POPUP_GROUP, ACTION_SORT_FIRST + index, index, sortOrder.titleRes).apply { + isChecked = viewModel.options.value.sortOrder == sortOrder + } + } + menu.setGroupCheckable(POPUP_GROUP, true, true) + setOnMenuItemClickListener { item -> + val sortOrder = AlternativeSortOrder.entries.getOrNull(item.itemId - ACTION_SORT_FIRST) + ?: return@setOnMenuItemClickListener false + viewModel.setSortOrder(sortOrder) + true + } + show() + } + } + + override fun onChipCloseClick(chip: Chip, data: Any?) { + when (data as? SummaryChip) { + SummaryChip.QUERY -> viewModel.setQuery(viewModel.manga.title) + SummaryChip.SOURCE_SCOPE -> viewModel.setSourceScope(viewModel.defaultScopeForReset()) + SummaryChip.LANGUAGE -> viewModel.setSameLanguageOnly(false) + SummaryChip.CONTENT_TYPE -> viewModel.setSameContentTypeOnly(false) + SummaryChip.HAS_CHAPTERS -> viewModel.setHideNoChapters(false) + SummaryChip.SORT -> viewModel.setSortOrder(AlternativeSortOrder.BEST_MATCH) + SummaryChip.RESET -> viewModel.resetOptions() + SummaryChip.STATUS, null -> Unit + } + } private fun confirmMigration(target: Manga) { buildAlertDialog(this, isCentered = true) { @@ -116,9 +203,131 @@ class AlternativesActivity : BaseActivity(), ), ) setNegativeButton(android.R.string.cancel, null) - setPositiveButton(R.string.migrate) { _, _ -> - viewModel.migrate(target) - } + setPositiveButton(R.string.migrate) { _, _ -> viewModel.migrate(target) } }.show() } + + private fun buildReferenceSubtitle(reference: MangaAlternativeReference): String = buildList { + add(reference.manga.source.getTitle(this@AlternativesActivity)) + reference.manga.source.languageCode?.uppercase()?.let(::add) + add( + resources.getQuantityStringSafe( + R.plurals.chapters, + reference.chaptersCount, + reference.chaptersCount, + ), + ) + }.joinToString(" • ") + + private fun updateSummaryChips() { + val options = viewModel.options.value + val status = viewModel.searchStatus.value + val resultCount = resources.getQuantityString( + R.plurals.search_results_count, + status.resultsCount, + status.resultsCount, + ) + val statusText = when { + !status.isRunning -> resultCount + status.totalSources == 0 -> getString(R.string.preparing_search) + else -> getString(R.string.searching_sources_progress, status.completedSources, status.totalSources) + } + val sourceScopeTitle = SourceScopeMenu.title(this, options.sourceScope, viewModel.scopeOptions.value) + viewBinding.chipsSummary.setChips( + buildList { + add( + ChipsView.ChipModel( + title = options.query, + icon = appcompatR.drawable.abc_ic_search_api_material, + isCloseable = options.query != viewModel.manga.title, + data = SummaryChip.QUERY, + ), + ) + add(ChipsView.ChipModel(title = statusText, isLoading = status.isRunning, data = SummaryChip.STATUS)) + add( + ChipsView.ChipModel( + title = sourceScopeTitle, + icon = R.drawable.ic_manga_source, + isCloseable = options.sourceScope != viewModel.defaultScopeForReset(), + data = SummaryChip.SOURCE_SCOPE, + ), + ) + if (options.sameLanguageOnly) add( + ChipsView.ChipModel( + titleResId = R.string.same_language, + icon = R.drawable.ic_language, + isCloseable = true, + data = SummaryChip.LANGUAGE, + ), + ) + if (options.sameContentTypeOnly) add( + ChipsView.ChipModel( + titleResId = R.string.same_content_type, + isCloseable = true, + data = SummaryChip.CONTENT_TYPE, + ), + ) + if (options.hideNoChapters) add( + ChipsView.ChipModel( + titleResId = R.string.has_chapters, + icon = R.drawable.ic_current_chapter, + isCloseable = true, + data = SummaryChip.HAS_CHAPTERS, + ), + ) + add( + ChipsView.ChipModel( + titleResId = options.sortOrder.titleRes, + icon = R.drawable.ic_sort_desc, + isCloseable = options.sortOrder != AlternativeSortOrder.BEST_MATCH, + data = SummaryChip.SORT, + ), + ) + if (viewModel.hasCustomOptions()) add( + ChipsView.ChipModel( + titleResId = R.string.reset, + icon = R.drawable.ic_clear_all, + data = SummaryChip.RESET, + ), + ) + }, + ) + } + + private fun updateSearchProgress(status: AlternativeSearchStatus) { + with(viewBinding.progressSources) { + if (status.totalSources > 0) { + isIndeterminate = false + max = status.totalSources + setProgressCompat(status.completedSources.coerceAtMost(status.totalSources), true) + } else { + isIndeterminate = true + } + isVisible = status.isRunning + } + } + + private val AlternativeSortOrder.titleRes: Int + get() = when (this) { + AlternativeSortOrder.BEST_MATCH -> R.string.best_match + AlternativeSortOrder.MOST_CHAPTERS -> R.string.most_chapters + AlternativeSortOrder.CLOSEST_CHAPTER_COUNT -> R.string.closest_chapter_count + AlternativeSortOrder.SOURCE_PRIORITY -> R.string.source_priority + } + + private enum class SummaryChip { + QUERY, + SOURCE_SCOPE, + LANGUAGE, + CONTENT_TYPE, + HAS_CHAPTERS, + SORT, + STATUS, + RESET, + } + + private companion object { + const val POPUP_GROUP = Menu.FIRST + 2000 + const val ACTION_SORT_FIRST = Menu.FIRST + 2010 + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesMenuProvider.kt new file mode 100644 index 0000000000..2c4b1d8d08 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesMenuProvider.kt @@ -0,0 +1,87 @@ +package io.github.landwarderer.futon.alternatives.ui + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.domain.AlternativeSortOrder + +class AlternativesMenuProvider( + private val activity: AlternativesActivity, + private val viewModel: AlternativesViewModel, +) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_regular_alternatives, menu) + val searchItem = menu.findItem(R.id.action_search) + searchItem.setOnActionExpandListener(this) + (searchItem.actionView as SearchView).run { + setOnQueryTextListener(this@AlternativesMenuProvider) + queryHint = activity.getString(R.string.search_manga) + } + } + + override fun onPrepareMenu(menu: Menu) { + val options = viewModel.options.value + menu.findItem(R.id.action_alternatives_sources)?.subMenu?.let { sourcesMenu -> + sourcesMenu.clear() + SourceScopeMenu.populate(sourcesMenu, SOURCES_GROUP, viewModel.scopeOptions.value, options.sourceScope) + } + menu.findItem(R.id.action_alternatives_reset)?.isVisible = viewModel.hasCustomOptions() + menu.findItem(R.id.action_alternatives_same_language)?.isChecked = options.sameLanguageOnly + menu.findItem(R.id.action_alternatives_same_content_type)?.isChecked = options.sameContentTypeOnly + menu.findItem(R.id.action_alternatives_hide_no_chapters)?.isChecked = options.hideNoChapters + menu.findItem( + when (options.sortOrder) { + AlternativeSortOrder.BEST_MATCH -> R.id.action_alternatives_sort_best_match + AlternativeSortOrder.MOST_CHAPTERS -> R.id.action_alternatives_sort_most_chapters + AlternativeSortOrder.CLOSEST_CHAPTER_COUNT -> R.id.action_alternatives_sort_closest_chapters + AlternativeSortOrder.SOURCE_PRIORITY -> R.id.action_alternatives_sort_source_priority + }, + )?.isChecked = true + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + val options = viewModel.options.value + when (menuItem.itemId) { + + R.id.action_alternatives_same_language -> viewModel.setSameLanguageOnly(!options.sameLanguageOnly) + R.id.action_alternatives_same_content_type -> viewModel.setSameContentTypeOnly(!options.sameContentTypeOnly) + R.id.action_alternatives_hide_no_chapters -> viewModel.setHideNoChapters(!options.hideNoChapters) + R.id.action_alternatives_sort_best_match -> viewModel.setSortOrder(AlternativeSortOrder.BEST_MATCH) + R.id.action_alternatives_sort_most_chapters -> viewModel.setSortOrder(AlternativeSortOrder.MOST_CHAPTERS) + R.id.action_alternatives_sort_closest_chapters -> + viewModel.setSortOrder(AlternativeSortOrder.CLOSEST_CHAPTER_COUNT) + + R.id.action_alternatives_sort_source_priority -> viewModel.setSortOrder(AlternativeSortOrder.SOURCE_PRIORITY) + R.id.action_alternatives_reset -> viewModel.resetOptions() + else -> viewModel.setSourceScope( + SourceScopeMenu.scopeFor(menuItem.itemId, viewModel.scopeOptions.value) ?: return false, + ) + } + activity.invalidateMenu() + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + query?.takeIf(String::isNotBlank)?.let(viewModel::setQuery) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + (item.actionView as? SearchView)?.post { + (item.actionView as? SearchView)?.setQuery(viewModel.options.value.query, false) + } + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean = true + + private companion object { + const val SOURCES_GROUP = Menu.FIRST + 4000 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesViewModel.kt index 5009f1aa93..a04a99cf50 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/AlternativesViewModel.kt @@ -9,33 +9,44 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.domain.AlternativeSearchEvent +import io.github.landwarderer.futon.alternatives.domain.AlternativeSearchStatus +import io.github.landwarderer.futon.alternatives.domain.AlternativeSortOrder +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScope +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScopeOptions +import io.github.landwarderer.futon.alternatives.domain.AlternativesSearchOptions import io.github.landwarderer.futon.alternatives.domain.AlternativesUseCase import io.github.landwarderer.futon.alternatives.domain.MigrateUseCase import io.github.landwarderer.futon.core.model.chaptersCount +import io.github.landwarderer.futon.core.model.contentTypeCompat +import io.github.landwarderer.futon.core.model.primaryLanguage import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaRepository import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.util.ext.MutableEventFlow -import io.github.landwarderer.futon.core.util.ext.append import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.core.util.ext.require import io.github.landwarderer.futon.list.domain.MangaListMapper -import io.github.landwarderer.futon.list.ui.model.ButtonFooter import io.github.landwarderer.futon.list.ui.model.EmptyState import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingFooter import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.list.ui.model.MangaGridModel import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy +import java.util.Locale import javax.inject.Inject +import kotlin.math.abs @HiltViewModel class AlternativesViewModel @Inject constructor( @@ -48,8 +59,14 @@ class AlternativesViewModel @Inject constructor( val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga - private var includeDisabledSources = MutableStateFlow(false) private val results = MutableStateFlow>(emptyList()) + private val sourceSearchStatus = MutableStateFlow(AlternativeSearchStatus()) + val options = MutableStateFlow(AlternativesSearchOptions(query = manga.title)) + val referenceManga = MutableStateFlow(MangaAlternativeReference(manga)) + private val _scopeOptions = MutableStateFlow(null) + val scopeOptions = _scopeOptions.asStateFlow() + private var defaultSourceScope: AlternativeSourceScope = AlternativeSourceScope.Enabled + private var sourceScopeInitialized = false private var migrationJob: Job? = null private var searchJob: Job? = null @@ -60,16 +77,29 @@ class AlternativesViewModel @Inject constructor( val onMigrated = MutableEventFlow() + val searchStatus: StateFlow = combine( + sourceSearchStatus, + results, + options, + ) { status, results, options -> + status.copy(resultsCount = results.count { !options.hideNoChapters || it.chaptersCount > 0 }) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, AlternativeSearchStatus()) + val list: StateFlow> = combine( results, isLoading, - includeDisabledSources, - ) { list, loading, includeDisabled -> + options, + referenceManga, + ) { results, loading, options, reference -> + val filtered = results + .filterNot { options.hideNoChapters && it.chaptersCount == 0 } + .sortedWith(alternativeComparator(options, reference.manga, reference.chaptersCount)) when { - list.isEmpty() -> listOf( - when { - loading -> LoadingState - else -> EmptyState( + filtered.isEmpty() -> listOf( + if (loading) { + LoadingState() + } else { + EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = R.string.text_search_holder_secondary, @@ -78,37 +108,63 @@ class AlternativesViewModel @Inject constructor( }, ) - loading -> list + LoadingFooter() - includeDisabled -> list - else -> list + ButtonFooter(R.string.search_disabled_sources) + loading -> filtered + LoadingFooter() + else -> filtered } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { - doSearch(throughDisabledSources = false) + doSearch() } - fun retry() { - searchJob?.cancel() - results.value = emptyList() - includeDisabledSources.value = false - doSearch(throughDisabledSources = false) + fun retry() = doSearch() + + fun setQuery(query: String) { + val value = query.trim() + if (value.isEmpty() || value == options.value.query) return + updateSearchOptions { copy(query = value) } } - fun continueSearch() { - if (includeDisabledSources.value) { - return - } - val prevJob = searchJob - searchJob = launchLoadingJob(Dispatchers.IO) { - includeDisabledSources.value = true - prevJob?.join() - doSearch(throughDisabledSources = true) - } + fun setSourceScope(scope: AlternativeSourceScope) { + if (scope == options.value.sourceScope || scopeOptions.value?.isAvailable(scope) != true) return + updateSearchOptions { copy(sourceScope = scope) } + } + + fun setSameLanguageOnly(enabled: Boolean) { + if (enabled != options.value.sameLanguageOnly) updateSearchOptions { copy(sameLanguageOnly = enabled) } + } + + fun setSameContentTypeOnly(enabled: Boolean) { + if (enabled != options.value.sameContentTypeOnly) updateSearchOptions { copy(sameContentTypeOnly = enabled) } } + fun setHideNoChapters(enabled: Boolean) { + if (enabled != options.value.hideNoChapters) options.update { it.copy(hideNoChapters = enabled) } + } + + fun setSortOrder(sortOrder: AlternativeSortOrder) { + if (sortOrder != options.value.sortOrder) options.update { it.copy(sortOrder = sortOrder) } + } + + fun resetOptions() { + val defaults = AlternativesSearchOptions(query = manga.title, sourceScope = defaultSourceScope) + val current = options.value + if (current == defaults) return + val requiresSearch = current.query != defaults.query || + current.sourceScope != defaults.sourceScope || + current.sameLanguageOnly != defaults.sameLanguageOnly || + current.sameContentTypeOnly != defaults.sameContentTypeOnly + options.value = defaults + if (requiresSearch) doSearch() + } + + fun hasCustomOptions(): Boolean = !options.value.isDefaultFor(manga.title, defaultSourceScope) + + fun defaultScopeForReset(): AlternativeSourceScope = defaultSourceScope + fun migrate(target: Manga) { - if (migrationJob?.isActive == true) { + val candidate = results.value.find { it.manga.id == target.id } + if (migrationJob?.isActive == true || candidate?.isMigrationEnabled != true || candidate.chaptersCount == 0) { return } migrationJob = launchLoadingJob(Dispatchers.IO) { @@ -117,20 +173,135 @@ class AlternativesViewModel @Inject constructor( } } - private fun doSearch(throughDisabledSources: Boolean) { - val prevJob = searchJob + private fun updateSearchOptions(block: AlternativesSearchOptions.() -> AlternativesSearchOptions) { + options.update(block) + doSearch() + } + + private fun doSearch() { + results.update { current -> current.map { it.copy(isMigrationEnabled = false) } } + val previousJob = searchJob searchJob = launchLoadingJob(Dispatchers.IO) { - prevJob?.cancelAndJoin() - val ref = mangaDetails.getOrDefault(manga) - val refCount = ref.chaptersCount() - alternativesUseCase.invoke(ref, throughDisabledSources) - .collect { - val model = MangaAlternativeModel( - mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel, - referenceChapters = refCount, - ) - results.append(model) + previousJob?.cancelAndJoin() + sourceSearchStatus.value = AlternativeSearchStatus(isRunning = true) + var receivedResult = false + try { + val reference = mangaDetails.getOrDefault(manga) + val referenceChapters = reference.chaptersCount() + referenceManga.value = MangaAlternativeReference(reference, referenceChapters) + if (!sourceScopeInitialized) { + val scopeOptions = alternativesUseCase.getSourceScopeOptions(reference.source) + defaultSourceScope = scopeOptions.defaultScope + _scopeOptions.value = scopeOptions + options.update { it.copy(sourceScope = defaultSourceScope) } + sourceScopeInitialized = true + } + val searchOptions = options.value + alternativesUseCase(reference, searchOptions).collect { event -> + when (event) { + is AlternativeSearchEvent.Progress -> sourceSearchStatus.update { + it.copy( + completedSources = maxOf(it.completedSources, event.completedSources), + totalSources = event.totalSources, + ) + } + + is AlternativeSearchEvent.Result -> { + if (!receivedResult) { + results.value = emptyList() + receivedResult = true + } + val model = MangaAlternativeModel( + mangaModel = mangaListMapper.toListModel(event.manga, ListMode.GRID) as MangaGridModel, + referenceChapters = referenceChapters, + ) + results.update { current -> + current.filterNot { it.manga.id == model.manga.id } + model + } + } + } } + if (!receivedResult) results.value = emptyList() + } finally { + sourceSearchStatus.update { it.copy(isRunning = false) } + } } } + + private fun alternativeComparator( + options: AlternativesSearchOptions, + reference: Manga, + referenceChapters: Int, + ): Comparator = Comparator { left, right -> + val primary = when (options.sortOrder) { + AlternativeSortOrder.BEST_MATCH -> compareValues( + right.manga.titleMatchScore(options.query), + left.manga.titleMatchScore(options.query), + ).takeUnless { it == 0 } ?: compareValues( + right.chaptersCount > 0, + left.chaptersCount > 0, + ).takeUnless { it == 0 } ?: compareValues( + right.manga.source.priority(reference.source), + left.manga.source.priority(reference.source), + ) + + AlternativeSortOrder.MOST_CHAPTERS -> compareValues(right.chaptersCount, left.chaptersCount) + AlternativeSortOrder.CLOSEST_CHAPTER_COUNT -> compareValues( + left.chapterDistanceFrom(referenceChapters), + right.chapterDistanceFrom(referenceChapters), + ) + AlternativeSortOrder.SOURCE_PRIORITY -> compareValues( + right.manga.source.priority(reference.source), + left.manga.source.priority(reference.source), + ) + } + primary.takeUnless { it == 0 } + ?: compareValues(right.chaptersCount, left.chaptersCount).takeUnless { it == 0 } + ?: left.manga.source.name.compareTo(right.manga.source.name, ignoreCase = true).takeUnless { it == 0 } + ?: left.manga.title.compareTo(right.manga.title, ignoreCase = true).takeUnless { it == 0 } + ?: left.manga.id.compareTo(right.manga.id) + } + + private fun MangaAlternativeModel.chapterDistanceFrom(referenceChapters: Int): Int = + if (chaptersCount == 0) Int.MAX_VALUE else abs(chaptersCount - referenceChapters) + + private fun Manga.titleMatchScore(query: String): Int { + val normalizedQuery = query.normalizeTitle() + if (normalizedQuery.isEmpty()) return 0 + return (sequenceOf(title) + altTitles.asSequence()).maxOf { candidate -> + val normalizedCandidate = candidate.normalizeTitle() + when { + normalizedCandidate == normalizedQuery -> 100 + normalizedCandidate.startsWith(normalizedQuery) || normalizedQuery.startsWith(normalizedCandidate) -> 85 + normalizedCandidate.contains(normalizedQuery) || normalizedQuery.contains(normalizedCandidate) -> 75 + else -> { + val queryTokens = normalizedQuery.splitToSequence(' ').filter(String::isNotEmpty).toSet() + val candidateTokens = normalizedCandidate.splitToSequence(' ').filter(String::isNotEmpty).toSet() + val total = maxOf(queryTokens.size, candidateTokens.size) + if (total == 0) 0 else queryTokens.intersect(candidateTokens).size * 60 / total + } + } + } + } + + private fun String.normalizeTitle(): String = lowercase(Locale.ROOT) + .replace(NON_TITLE_CHARACTER, " ") + .trim() + .replace(MULTIPLE_SPACES, " ") + + private fun MangaSource.priority(ref: MangaSource): Int { + var result = 0 + val language = primaryLanguage + if (language != null) { + if (language == ref.primaryLanguage) result += 4 else if (language == Locale.getDefault().language) result += 2 + } + val type = contentTypeCompat + if (type != null && type == ref.contentTypeCompat) result++ + return result + } + + private companion object { + val NON_TITLE_CHARACTER = Regex("[^\\p{L}\\p{N}]+") + val MULTIPLE_SPACES = Regex("\\s+") + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaAlternativeModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaAlternativeModel.kt index 078045880c..aadaf81f19 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaAlternativeModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaAlternativeModel.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga data class MangaAlternativeModel( val mangaModel: MangaGridModel, private val referenceChapters: Int, + val isMigrationEnabled: Boolean = true, ) : ListModel { val manga: Manga @@ -28,3 +29,8 @@ data class MangaAlternativeModel( null } } + +data class MangaAlternativeReference( + val manga: Manga, + val chaptersCount: Int = manga.chaptersCount(), +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaUnavailableDialog.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaUnavailableDialog.kt new file mode 100644 index 0000000000..86919de001 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/MangaUnavailableDialog.kt @@ -0,0 +1,66 @@ +package io.github.landwarderer.futon.alternatives.ui + +import android.content.Context +import android.content.DialogInterface +import android.view.LayoutInflater +import androidx.annotation.UiContext +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.model.isLocal +import io.github.landwarderer.futon.databinding.DialogMangaUnavailableBinding +import org.koitharu.kotatsu.parsers.model.Manga + +/** + * Prompt shown when a manga the user just opened is gone from its source (404 / removed), offering to + * look for the same title elsewhere instead of leaving them with a dead-end error. + */ +class MangaUnavailableDialog private constructor( + private val delegate: AlertDialog, + private val binding: DialogMangaUnavailableBinding, + private val manga: Manga, +) : DialogInterface by delegate { + + val isShowing: Boolean + get() = delegate.isShowing + + fun show() { + delegate.show() + // After show(), so the view is attached and Coil can bind to the dialog's lifecycle. + binding.imageViewCover.setImageAsync(manga.coverUrl, manga) + } + + class Builder(@UiContext context: Context, private val manga: Manga) { + + private val binding = DialogMangaUnavailableBinding.inflate(LayoutInflater.from(context)) + + private val delegate = MaterialAlertDialogBuilder(context) + .setView(binding.root) + + init { + binding.textViewTitle.setText(R.string.manga_unavailable_title) + val sourceTitle = if (manga.isLocal) null else manga.source.getTitle(context) + binding.textViewMessage.text = if (sourceTitle != null) { + context.getString(R.string.manga_unavailable_message, manga.title, sourceTitle) + } else { + context.getString(R.string.manga_unavailable_message_no_source, manga.title) + } + } + + fun setOnAlternativesClickListener(listener: Runnable) = apply { + binding.button1.setOnClickListener { + val dialog = binding.root.tag as DialogInterface + dialog.dismiss() + listener.run() + } + } + + fun create(): MangaUnavailableDialog { + val dialog = delegate.create() + binding.root.tag = dialog + binding.buttonClose.setOnClickListener { dialog.dismiss() } + return MangaUnavailableDialog(dialog, binding, manga) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementAD.kt new file mode 100644 index 0000000000..b3a04239fa --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementAD.kt @@ -0,0 +1,42 @@ +package io.github.landwarderer.futon.alternatives.ui + +import androidx.core.view.isVisible +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.databinding.ItemSourceReplacementBinding +import io.github.landwarderer.futon.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.MangaSource + +fun sourceReplacementAD( + onClick: (MangaSource?) -> Unit, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemSourceReplacementBinding.inflate(inflater, parent, false) }, +) { + binding.root.setOnClickListener { onClick(item.source) } + + bind { + val source = item.source + binding.textViewTitle.text = source?.getTitle(context) + ?: context.getString(R.string.handle_remaining_manually) + binding.textViewSubtitle.text = if (source == null) { + context.getString(R.string.handle_remaining_manually_summary) + } else { + context.getString( + R.string.source_match_rate_summary, + item.matched, + item.total, + ) + } + binding.textViewPercent.isVisible = source != null + binding.textViewPercent.text = context.getString(R.string.percent_format, item.matchPercent) + binding.progressMatch.isVisible = source != null + binding.progressMatch.setProgressCompat(item.matchPercent, false) + binding.imageViewSelected.isVisible = item.isSelected + if (source == null) { + binding.imageViewIcon.setImageResource(R.drawable.ic_web) + } else { + binding.imageViewIcon.setImageAsync(source) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementActivity.kt new file mode 100644 index 0000000000..a60862f349 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementActivity.kt @@ -0,0 +1,507 @@ +package io.github.landwarderer.futon.alternatives.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import android.view.View +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import coil3.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.nav.router +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.ui.BaseListAdapter +import io.github.landwarderer.futon.core.ui.dialog.buildAlertDialog +import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener +import io.github.landwarderer.futon.core.util.ext.consumeAllSystemBarsInsets +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.systemBarsInsets +import io.github.landwarderer.futon.databinding.ActivityAlternativesBinding +import io.github.landwarderer.futon.list.ui.adapter.ListItemType +import io.github.landwarderer.futon.list.ui.adapter.ListStateHolderListener +import io.github.landwarderer.futon.list.ui.adapter.TypedListSpacingDecoration +import io.github.landwarderer.futon.list.ui.adapter.buttonFooterAD +import io.github.landwarderer.futon.list.ui.adapter.loadingFooterAD +import io.github.landwarderer.futon.list.ui.adapter.loadingStateAD +import io.github.landwarderer.futon.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import javax.inject.Inject + +@AndroidEntryPoint +class SourceReplacementActivity : BaseActivity(), + ListStateHolderListener, + OnListItemClickListener { + + @Inject + lateinit var coil: ImageLoader + + private val viewModel by viewModels() + private var sourceMenuItem: MenuItem? = null + private var sourceScopeMenuItem: MenuItem? = null + private var sourceScopeSubMenu: SubMenu? = null + private var searchMenuItem: MenuItem? = null + private var nextSourceMenuItem: MenuItem? = null + private var skipMenuItem: MenuItem? = null + private var isShowingSummary = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityAlternativesBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + viewBinding.progressSources.isVisible = false + viewBinding.scrollViewSummary.isVisible = false + val listAdapter = BaseListAdapter() + .addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this)) + .addDelegate(ListItemType.SOURCE_REPLACEMENT, sourceReplacementAD(::onSourceMatchClick)) + .addDelegate( + ListItemType.STATE_EMPTY, + sourceReplacementEmptyStateAD( + onEditTitle = { searchMenuItem?.expandActionView() }, + onSkip = viewModel::skipCurrent, + ), + ) .addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) + .addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) + .addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this)) + with(viewBinding.recyclerView) { + setHasFixedSize(true) + addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false)) + adapter = listAdapter + } + + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.list.observe(this, listAdapter) + viewModel.currentManga.observe(this) { + updateHeader() + } + viewModel.queuePosition.observe(this) { + updateHeader() + } + viewModel.queueSize.observe(this) { + updateHeader() + } + viewModel.selectedSource.observe(this) { + updateHeader() + } + viewModel.matchedCount.observe(this) { + updateHeader() + } + viewModel.isPlanning.observe(this) { + updateHeader() + updatePlanningProgress() + } + viewModel.planningStep.observe(this) { + updatePlanningProgress() + } + viewModel.planningProgress.observe(this) { + updateHeader() + updatePlanningProgress() + } + viewModel.planningTotal.observe(this) { + updateHeader() + updatePlanningProgress() + } + viewModel.planningSourcesRemaining.observe(this) { + updatePlanningProgress() + } + viewModel.planningSampleSize.observe(this) { + updatePlanningProgress() + } + viewModel.planningCurrentSource.observe(this) { + updatePlanningProgress() + } + viewModel.isManualMode.observe(this) { + updateHeader() + } + viewModel.isComparingSources.observe(this) { + updateHeader() + } + viewModel.sourceScope.observe(this) { + updateSourceScopeMenu() + } + viewModel.scopeOptions.observe(this) { + updateSourceScopeMenu() + } + viewModel.onMigrated.observeEvent(this) { result -> + if (result.remaining > 0) { + Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() + } + } + viewModel.onBatchMigrated.observeEvent(this) { result -> + if (result.remaining > 0) { + val message = getString( + R.string.source_replacement_batch_completed, + result.replaced, + result.remaining, + ) + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + } + viewModel.replacementSummary.observe(this) { summary -> + summary?.let(::showReplacementSummary) + } + viewModel.onPlanReady.observeEvent(this) { + viewModel.showSourceComparison() + } + updateHeader() + updatePlanningProgress() + } + + override fun onStop() { + if (isFinishing) { + viewModel.cancelPendingChecks() + } + super.onStop() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + super.onCreateOptionsMenu(menu) + menuInflater.inflate(R.menu.opt_alternatives, menu) + sourceMenuItem = menu.findItem(R.id.action_choose_source) + searchMenuItem = menu.add(Menu.NONE, R.id.action_search, Menu.NONE, R.string.search).apply { + setIcon(androidx.appcompat.R.drawable.abc_ic_search_api_material) + setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) + actionView = SearchView(this@SourceReplacementActivity).apply { + queryHint = getString(R.string.search_manga) + setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + viewModel.search(query.orEmpty()) + clearFocus() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + }) + } + setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + (item.actionView as? SearchView)?.setQuery(viewModel.manga.title, false) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean = true + }) + } + nextSourceMenuItem = menu.add(Menu.NONE, ACTION_NEXT_SOURCE, Menu.NONE, R.string.next).apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + skipMenuItem = menu.add(Menu.NONE, ACTION_SKIP, Menu.NONE, R.string.skip).apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + val scopeMenu = menu.addSubMenu( + Menu.NONE, + ACTION_SOURCE_SCOPE, + Menu.NONE, + R.string.preset_sources, + ) + sourceScopeMenuItem = scopeMenu.item.apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + sourceScopeSubMenu = scopeMenu + updateSourceScopeMenu() + updateHeader() + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + finishAfterTransition() + true + } + + R.id.action_choose_source -> { + viewModel.showSourceComparison() + true + } + + ACTION_NEXT_SOURCE -> { + viewModel.selectNextSource() + true + } + + ACTION_SKIP -> { + viewModel.skipCurrent() + true + } + + else -> { + val scope = SourceScopeMenu.scopeFor(item.itemId, viewModel.scopeOptions.value) + if (scope != null) { + viewModel.selectSourceScope(scope) + true + } else { + super.onOptionsItemSelected(item) + } + } + } + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.recyclerView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.summaryContainer.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, + ) + return insets.consumeAllSystemBarsInsets() + } + + override fun onItemClick(item: MangaAlternativeModel, view: View) { + when (view.id) { + R.id.chip_source -> viewModel.selectSource(item.manga.source) + R.id.button_migrate -> confirmMigration(item.manga) + else -> router.openDetails(item.manga) + } + } + + override fun onRetryClick(error: Throwable) = viewModel.retry() + + override fun onEmptyActionClick() { + if (viewModel.selectedSource.value == null) { + viewModel.showSourceComparison() + } else { + searchMenuItem?.expandActionView() + } + } + + override fun onFooterButtonClick() { + if (viewModel.isComparingSources.value) { + viewModel.skipRemaining() + } else { + viewModel.skipCurrent() + } + } + /** Outside manual mode, picking a source migrates every matched title at once, so ask first */ + private fun onSourceMatchClick(source: MangaSource?) { + if (source == null || viewModel.isManualMode.value) { + viewModel.selectSource(source) + return + } + buildAlertDialog(this, isCentered = true) { + setIcon(R.drawable.ic_replace) + setTitle(R.string.replace_source) + setMessage(getString(R.string.source_replacement_confirm, source.getTitle(context))) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.replace) { _, _ -> + viewModel.selectSource(source) + } + }.show() + } + + private fun confirmMigration(target: Manga) { + val source = viewModel.manga + buildAlertDialog(this, isCentered = true) { + setIcon(R.drawable.ic_replace) + setTitle(R.string.replace_source) + setMessage( + getString( + R.string.migrate_confirmation, + source.title, + source.source.getTitle(context), + target.title, + target.source.getTitle(context), + ), + ) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.replace) { _, _ -> + viewModel.migrate(target) + } + }.show() + } + + private fun updateHeader() { + if (isShowingSummary) { + supportActionBar?.subtitle = null + sourceMenuItem?.isVisible = false + sourceScopeMenuItem?.isVisible = false + searchMenuItem?.isVisible = false + nextSourceMenuItem?.isVisible = false + skipMenuItem?.isVisible = false + return + } + val total = viewModel.queueSize.value + if (viewModel.isPlanning.value) { + supportActionBar?.subtitle = null + sourceMenuItem?.isEnabled = false + sourceScopeMenuItem?.isEnabled = false + searchMenuItem?.isVisible = false + nextSourceMenuItem?.isVisible = false + skipMenuItem?.isVisible = false + return + } + if (viewModel.isComparingSources.value) { + supportActionBar?.subtitle = getString(R.string.compare_source_matches) + sourceMenuItem?.isVisible = false + searchMenuItem?.isVisible = false + nextSourceMenuItem?.isVisible = false + skipMenuItem?.isVisible = viewModel.isManualMode.value + return + } + sourceMenuItem?.isVisible = true + sourceScopeMenuItem?.isVisible = true + sourceScopeMenuItem?.isEnabled = true + val manual = viewModel.isManualMode.value + searchMenuItem?.isVisible = manual && viewModel.selectedSource.value != null + nextSourceMenuItem?.isVisible = manual + skipMenuItem?.isVisible = manual + val target = viewModel.selectedSource.value?.getTitle(this) ?: getString(R.string.top_source_matches) + val manga = viewModel.manga + supportActionBar?.subtitle = if (total > 1) { + getString( + R.string.source_replacement_subtitle_batch, + manga.title, + viewModel.queuePosition.value, + total, + target, + ) + } else { + getString(R.string.source_replacement_subtitle, manga.title, target) + } + sourceMenuItem?.title = if (viewModel.selectedSource.value == null) { + getString(R.string.choose_source) + } else { + getString(R.string.source_selected, target) + } + sourceMenuItem?.isEnabled = true + } + + private fun updateSourceScopeMenu() { + val scopeOptions = viewModel.scopeOptions.value + sourceScopeSubMenu?.let { subMenu -> + subMenu.clear() + SourceScopeMenu.populate(subMenu, SOURCE_SCOPE_GROUP, scopeOptions, viewModel.sourceScope.value) + } + val selectedTitle = SourceScopeMenu.title(this, viewModel.sourceScope.value, scopeOptions) + sourceScopeMenuItem?.title = getString(R.string.preset_sources) + ": " + selectedTitle + } + + private fun showReplacementSummary(summary: SourceReplacementViewModel.ReplacementSummary) { + isShowingSummary = true + viewBinding.recyclerView.isVisible = false + viewBinding.planningProgressContainer.isVisible = false + viewBinding.summaryContainer.isVisible = true + supportActionBar?.title = getString(R.string.source_replacement_summary_title) + supportActionBar?.subtitle = null + sourceMenuItem?.isVisible = false + + viewBinding.textViewSummaryTitle.setText( + when (summary.replaced) { + 0 -> R.string.source_replacement_summary_zero_title + 1 -> R.string.source_replacement_summary_one_title + else -> R.string.source_replacement_summary_many_title + }, + ) + viewBinding.textViewSummaryMessage.text = when (summary.replaced) { + 0 -> getString(R.string.source_replacement_summary_zero_message) + 1 -> getString(R.string.source_replacement_summary_one_message) + else -> getString(R.string.source_replacement_summary_many_message, summary.replaced) + } + viewBinding.textViewSummaryReplaced.text = summary.replaced.toString() + viewBinding.textViewSummarySources.text = summary.sources.size.toString() + viewBinding.summaryMixContainer.isVisible = summary.sources.isNotEmpty() + viewBinding.textViewSummaryMix.text = summary.sources.joinToString(separator = " • ") { + getString( + R.string.source_replacement_summary_source_item, + it.source.getTitle(this), + it.count, + ) + } + viewBinding.buttonSummaryDone.setOnClickListener { + finishAfterTransition() + } + } + + private fun updatePlanningProgress() { + val planning = viewModel.isPlanning.value + viewBinding.planningProgressContainer.isVisible = planning + if (!planning) { + return + } + val completed = viewModel.planningProgress.value + val total = viewModel.planningTotal.value + val remaining = (total - completed).coerceAtLeast(0) + viewBinding.textViewPlanningStep.text = when (viewModel.planningStep.value) { + SourceReplacementStep.COMPARING_SOURCES -> if (total == 0) { + getString(R.string.source_replacement_step_preparing) + } else { + getString(R.string.source_replacement_step_comparing, viewModel.planningSampleSize.value) + } + + SourceReplacementStep.MATCHING_TITLES -> getString( + R.string.source_replacement_step_matching, + viewModel.selectedSource.value?.getTitle(this) ?: getString(R.string.automatic), + ) + + SourceReplacementStep.SMART_MIX -> getString( + R.string.source_replacement_step_smart_mix, + viewModel.planningCurrentSource.value?.getTitle(this) ?: getString(R.string.automatic), + ) + + SourceReplacementStep.APPLYING_MATCHES -> getString(R.string.source_replacement_step_applying) + } + viewBinding.textViewPlanningRemaining.text = when (viewModel.planningStep.value) { + SourceReplacementStep.COMPARING_SOURCES -> if (total == 0) { + null + } else { + getString( + R.string.source_replacement_remaining_checks, + viewModel.planningSourcesRemaining.value, + remaining, + ) + } + + SourceReplacementStep.MATCHING_TITLES -> getString( + R.string.source_replacement_remaining_titles, + remaining, + ) + + SourceReplacementStep.SMART_MIX -> getString( + R.string.source_replacement_remaining_mix, + viewModel.planningSourcesRemaining.value, + remaining, + ) + + SourceReplacementStep.APPLYING_MATCHES -> getString( + R.string.source_replacement_remaining_replacements, + remaining, + ) + } + with(viewBinding.planningProgressBar) { + if (total == 0) { + isIndeterminate = true + } else { + isIndeterminate = false + max = total + setProgressCompat(completed.coerceIn(0, total), true) + } + } + } + + private companion object { + const val ACTION_SOURCE_SCOPE = Menu.FIRST + 1000 + const val SOURCE_SCOPE_GROUP = Menu.FIRST + 1003 + const val ACTION_NEXT_SOURCE = Menu.FIRST + 1004 + const val ACTION_SKIP = Menu.FIRST + 1005 + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementEmptyStateAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementEmptyStateAD.kt new file mode 100644 index 0000000000..ec910765ad --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementEmptyStateAD.kt @@ -0,0 +1,56 @@ +package io.github.landwarderer.futon.alternatives.ui + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.databinding.ItemEmptyStateBinding +import io.github.landwarderer.futon.list.ui.model.ListModel + +object SourceReplacementEmptyState : ListModel { + override fun equals(other: Any?): Boolean = other === this + + override fun hashCode(): Int = javaClass.hashCode() + + override fun areItemsTheSame(other: ListModel): Boolean = other === this +} + +fun sourceReplacementEmptyStateAD( + onEditTitle: () -> Unit, + onSkip: () -> Unit, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }, +) { + val skipButton = MaterialButton( + context, + null, + com.google.android.material.R.attr.materialButtonOutlinedStyle, + ).apply { + setText(R.string.skip) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = resources.getDimensionPixelSize(R.dimen.margin_small) + } + setOnClickListener { onSkip() } + } + binding.root.addView(skipButton) + binding.buttonRetry.apply { + isVisible = true + setText(R.string.edit) + setOnClickListener { onEditTitle() } + } + + bind { + binding.icon.isVisible = true + binding.icon.setImageAsync(R.drawable.ic_empty_common) + binding.textPrimary.setText(R.string.nothing_found) + binding.textSecondary.apply { + isVisible = true + setText(R.string.source_replacement_empty_summary) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementModel.kt new file mode 100644 index 0000000000..a6866dc47e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementModel.kt @@ -0,0 +1,17 @@ +package io.github.landwarderer.futon.alternatives.ui + +import io.github.landwarderer.futon.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.MangaSource + +data class SourceReplacementModel( + val source: MangaSource?, + val matched: Int, + val total: Int, + val matchPercent: Int, + val isSelected: Boolean, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceReplacementModel && other.source?.name == source?.name + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementViewModel.kt new file mode 100644 index 0000000000..0d4cebf237 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceReplacementViewModel.kt @@ -0,0 +1,646 @@ +package io.github.landwarderer.futon.alternatives.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScope +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScopeOptions +import io.github.landwarderer.futon.alternatives.domain.AlternativesUseCase +import io.github.landwarderer.futon.alternatives.domain.MigrateUseCase +import io.github.landwarderer.futon.alternatives.domain.SourceReplacementPlan +import io.github.landwarderer.futon.alternatives.domain.SourceReplacementPlannerUseCase +import io.github.landwarderer.futon.core.model.chaptersCount +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.ListMode +import io.github.landwarderer.futon.core.ui.BaseViewModel +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.require +import io.github.landwarderer.futon.list.domain.MangaListMapper +import io.github.landwarderer.futon.list.ui.model.ButtonFooter +import io.github.landwarderer.futon.list.ui.model.ListModel +import io.github.landwarderer.futon.list.ui.model.LoadingFooter +import io.github.landwarderer.futon.list.ui.model.LoadingState +import io.github.landwarderer.futon.list.ui.model.MangaGridModel +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject + +@HiltViewModel +class SourceReplacementViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val mangaRepositoryFactory: MangaRepository.Factory, + private val mangaDataRepository: MangaDataRepository, + private val alternativesUseCase: AlternativesUseCase, + private val replacementPlanner: SourceReplacementPlannerUseCase, + private val migrateUseCase: MigrateUseCase, + private val mangaListMapper: MangaListMapper, +) : BaseViewModel() { + + private val requestedManga: List = savedStateHandle + .get>(AppRouter.KEY_MANGA_LIST) + ?.map(ParcelableManga::manga) + ?.distinctBy(Manga::id) + ?.takeIf(List::isNotEmpty) + ?: listOf(savedStateHandle.require(AppRouter.KEY_MANGA).manga) + private val originSource = requestedManga.first().source + private var mangaQueue = requestedManga.filter { it.source.name == originSource.name } + private val originalSourceNames = setOf(originSource.name) + private var currentIndex = (savedStateHandle.get(AppRouter.KEY_INDEX) ?: 0) + .coerceIn(mangaQueue.indices) + + private val _currentManga = MutableStateFlow(mangaQueue[currentIndex]) + val currentManga = _currentManga.asStateFlow() + val manga: Manga + get() = _currentManga.value + + private val _queueSize = MutableStateFlow(mangaQueue.size) + val queueSize = _queueSize.asStateFlow() + private val _queuePosition = MutableStateFlow(currentIndex + 1) + val queuePosition = _queuePosition.asStateFlow() + + private val _selectedSource = MutableStateFlow(null) + val selectedSource = _selectedSource.asStateFlow() + private val _matchedCount = MutableStateFlow(0) + val matchedCount = _matchedCount.asStateFlow() + + private val results = MutableStateFlow>(emptyList()) + private val sourceComparisonItems = MutableStateFlow>(emptyList()) + private val _isComparingSources = MutableStateFlow(false) + val isComparingSources = _isComparingSources.asStateFlow() + + private val replacementPlans = MutableStateFlow>(emptyList()) + private val failedSourceNames = mutableSetOf() + private val usedSourceNames = mutableSetOf() + private val activeMatches = MutableStateFlow>(emptyMap()) + private val _isManualMode = MutableStateFlow(false) + val isManualMode = _isManualMode.asStateFlow() + private val _isPlanning = MutableStateFlow(true) + val isPlanning = _isPlanning.asStateFlow() + private val _planningStep = MutableStateFlow(SourceReplacementStep.COMPARING_SOURCES) + val planningStep = _planningStep.asStateFlow() + private val _planningProgress = MutableStateFlow(0) + val planningProgress = _planningProgress.asStateFlow() + private val _planningTotal = MutableStateFlow(0) + val planningTotal = _planningTotal.asStateFlow() + private val _planningSourcesRemaining = MutableStateFlow(0) + val planningSourcesRemaining = _planningSourcesRemaining.asStateFlow() + private val _planningSampleSize = MutableStateFlow(0) + val planningSampleSize = _planningSampleSize.asStateFlow() + private val _planningCurrentSource = MutableStateFlow(null) + val planningCurrentSource = _planningCurrentSource.asStateFlow() + private val _sourceScope = MutableStateFlow(AlternativeSourceScope.Enabled) + val sourceScope = _sourceScope.asStateFlow() + private val _scopeOptions = MutableStateFlow(null) + val scopeOptions = _scopeOptions.asStateFlow() + private var sourceScopeInitialized = false + private var queueInitialized = false + + private var planningJob: Job? = null + private var migrationJob: Job? = null + private var searchJob: Job? = null + private var manualSourceOrder = emptyList() + private val manualTriedSourceNames = mutableSetOf() + + val onPlanReady = MutableEventFlow() + val onMigrated = MutableEventFlow() + val onBatchMigrated = MutableEventFlow() + private val _replacementSummary = MutableStateFlow(null) + val replacementSummary = _replacementSummary.asStateFlow() + private val replacementSourceUsage = linkedMapOf() + private var replacementCount = 0 + + private val screenState = combine( + isPlanning, + isComparingSources, + sourceComparisonItems, + ) { planning, comparing, sourceItems -> + ReplacementScreenState(planning, comparing, sourceItems) + } + + val list: StateFlow> = combine( + results, + isLoading, + selectedSource, + screenState, + ) { result, loading, source, screen -> + when { + screen.planning -> emptyList() + screen.comparing -> screen.sourceItems + if (isManualMode.value) { + listOf(ButtonFooter(R.string.skip)) + } else { + emptyList() + } + result.isEmpty() -> listOf( + when { + loading -> LoadingState() + source == null -> ButtonFooter(R.string.choose_source) + else -> SourceReplacementEmptyState + }, + ) + + loading -> result + LoadingFooter() + else -> result + } + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, emptyList()) + + init { + buildReplacementPlan() + } + + fun selectSource(source: MangaSource?) { + if (isPlanning.value) { + return + } + if (source?.name in originalSourceNames) { + return + } + _isComparingSources.value = false + sourceComparisonItems.value = emptyList() + if (source == null) { + _isManualMode.value = true + _selectedSource.value = null + activeMatches.value = emptyMap() + results.value = emptyList() + updateMatchedCount() + return + } + _selectedSource.value = source + if (isManualMode.value) { + manualTriedSourceNames += source.name + activeMatches.value = emptyMap() + updateMatchedCount() + startSearch() + } else { + resolveSource(source) + } + } + + fun retry() { + if (selectedSource.value == null) { + showSourceComparison() + } else { + startSearch() + } + } + + fun search(query: String) { + if (!isManualMode.value || selectedSource.value == null) { + return + } + val normalizedQuery = query.trim() + if (normalizedQuery.isNotEmpty()) { + startSearch(normalizedQuery) + } + } + + fun selectNextSource() { + if (isPlanning.value || !isManualMode.value) { + return + } + val next = manualSourceOrder.firstOrNull { it.name !in manualTriedSourceNames } + if (next == null) { + showSourceComparison() + } else { + selectSource(next) + } + } + + fun skipCurrent() { + if (isPlanning.value || migrationJob?.isActive == true || mangaQueue.isEmpty()) { + return + } + planningJob?.cancel() + searchJob?.cancel() + val skipped = manga + mangaQueue = mangaQueue.filterNot { it.id == skipped.id } + _selectedSource.value = null + results.value = emptyList() + activeMatches.value = emptyMap() + manualSourceOrder = emptyList() + manualTriedSourceNames.clear() + _queueSize.value = mangaQueue.size + updateMatchedCount() + if (mangaQueue.isEmpty()) { + _replacementSummary.value = createReplacementSummary() + return + } + currentIndex = currentIndex.coerceAtMost(mangaQueue.lastIndex) + savedStateHandle[AppRouter.KEY_INDEX] = currentIndex + _currentManga.value = mangaQueue[currentIndex] + _queuePosition.value = currentIndex + 1 + showSourceComparison() + } + + fun skipRemaining() { + if (isPlanning.value || migrationJob?.isActive == true || mangaQueue.isEmpty()) { + return + } + planningJob?.cancel() + searchJob?.cancel() + mangaQueue = emptyList() + _selectedSource.value = null + _isComparingSources.value = false + results.value = emptyList() + sourceComparisonItems.value = emptyList() + activeMatches.value = emptyMap() + manualSourceOrder = emptyList() + manualTriedSourceNames.clear() + _queueSize.value = 0 + updateMatchedCount() + _replacementSummary.value = createReplacementSummary() + } + + fun selectSourceScope(sourceScope: AlternativeSourceScope) { + if (sourceScope == _sourceScope.value || scopeOptions.value?.isAvailable(sourceScope) != true) { + return + } + sourceScopeInitialized = true + _sourceScope.value = sourceScope + _selectedSource.value = null + _isComparingSources.value = false + _isManualMode.value = false + sourceComparisonItems.value = emptyList() + activeMatches.value = emptyMap() + results.value = emptyList() + updateMatchedCount() + buildReplacementPlan() + } + + fun showSourceComparison() { + if (isPlanning.value) { + return + } + searchJob?.cancel() + _isComparingSources.value = true + sourceComparisonItems.value = emptyList() + searchJob = launchLoadingJob(Dispatchers.IO) { + val manual = isManualMode.value + val choices = getAvailableSources().let { available -> + if (manual) available.sortedBy { it.source.name } else available + } + val selectedName = selectedSource.value?.name + if (manual) { + manualSourceOrder = choices.map(SourceChoice::source) + } + sourceComparisonItems.value = choices.map { choice -> + SourceReplacementModel( + source = choice.source, + matched = choice.matched, + total = choice.total, + matchPercent = choice.matchPercent, + isSelected = choice.source.name == selectedName, + ) + } + SourceReplacementModel( + source = null, + matched = 0, + total = 0, + matchPercent = 0, + isSelected = false, + ) + } + } + + private suspend fun getAvailableSources(): List { + val excludedNames = originalSourceNames + usedSourceNames + failedSourceNames + val ranked = replacementPlans.value.filter { it.source.name !in excludedNames }.map { plan -> + SourceChoice( + source = plan.source, + matched = plan.matchedCount, + total = plan.total, + averageTitleSimilarity = plan.averageTitleSimilarity, + ) + }.sortedWith( + compareByDescending(SourceChoice::matched) + .thenByDescending(SourceChoice::averageTitleSimilarity), + ) + val rankedSourceNames = ranked.mapTo(hashSetOf()) { it.source.name } + val sampleSize = replacementPlans.value.firstOrNull()?.total ?: mangaQueue.size.coerceAtMost(25) + val other = alternativesUseCase.getCandidateSources( + ref = originSource, + sourceScope = sourceScope.value, + sameLanguageOnly = true, + ) + .filter { it.name !in excludedNames && it.name !in rankedSourceNames } + .sortedBy(MangaSource::name) + .map { SourceChoice(it, matched = 0, total = sampleSize, averageTitleSimilarity = 0f) } + return ranked + other + } + + fun migrate(target: Manga) { + if (migrationJob?.isActive == true) { + return + } + val seed = manga + migrationJob = launchLoadingJob(Dispatchers.IO) { + migrateUseCase(seed, target) + recordReplacement(target.source) + mangaQueue = mangaQueue.filterNot { it.id == seed.id } + val remaining = mangaQueue.size + if (remaining > 0) { + moveToFirstRemaining() + } + _queueSize.value = remaining + updateMatchedCount() + if (remaining > 0) { + _selectedSource.value = null + results.value = emptyList() + showSourceComparison() + } + onMigrated.call(MigrationResult(target, remaining)) + if (remaining == 0) { + _replacementSummary.value = createReplacementSummary() + } + } + } + + private fun buildReplacementPlan() { + planningJob?.cancel() + _isPlanning.value = true + _planningStep.value = SourceReplacementStep.COMPARING_SOURCES + _planningProgress.value = 0 + _planningTotal.value = 0 + _planningSourcesRemaining.value = 0 + _planningSampleSize.value = 0 + _planningCurrentSource.value = null + results.value = emptyList() + planningJob = launchLoadingJob(Dispatchers.IO) { + var ready: PlanReady? = null + try { + val scopeOptions = alternativesUseCase.getSourceScopeOptions( + ref = originSource, + sameLanguageOnly = true, + ) + _scopeOptions.value = scopeOptions + // Fall back to the default when the chosen scope went away, e.g. its preset was deleted. + if (!sourceScopeInitialized || !scopeOptions.isAvailable(_sourceScope.value)) { + _sourceScope.value = scopeOptions.defaultScope + sourceScopeInitialized = true + } + val sourceManga = mangaDataRepository.findAllMangaBySource(originSource) + if (!queueInitialized) { + val libraryManga = mangaDataRepository.findLibraryMangaBySource(originSource) + mangaQueue = (requestedManga.filter { it.source.name == originSource.name } + libraryManga) + .distinctBy(Manga::id) + queueInitialized = true + } + _replacementSummary.value = null + val initial = requestedManga.first { it.source.name == originSource.name } + currentIndex = mangaQueue.indexOfFirst { it.id == initial.id }.coerceAtLeast(0) + _currentManga.value = mangaQueue[currentIndex] + _queueSize.value = mangaQueue.size + _queuePosition.value = currentIndex + 1 + val comparisonPool = (mangaQueue + sourceManga).distinctBy(Manga::id) + val plans = replacementPlanner( + manga = comparisonPool, + sourceScope = sourceScope.value, + excludedSourceNames = originalSourceNames + usedSourceNames + failedSourceNames, + onProgress = { progress -> + _planningProgress.value = progress.completedChecks + _planningTotal.value = progress.totalChecks + _planningSourcesRemaining.value = progress.remainingSources + _planningSampleSize.value = progress.sampleSize + }, + onSourceFailed = { failedSourceNames += it.name }, + ) + val availablePlans = plans.filter { it.source.name !in usedSourceNames && it.source.name !in failedSourceNames } + replacementPlans.value = availablePlans + val best = availablePlans.firstOrNull() + _selectedSource.value = best?.source + activeMatches.value = emptyMap() + updateMatchedCount() + ready = PlanReady(best?.matchedCount ?: 0, mangaQueue.size) + } finally { + _isPlanning.value = false + } + ready?.let(onPlanReady::call) + } + } + + private fun resolveSource(source: MangaSource) { + planningJob?.cancel() + searchJob?.cancel() + results.value = emptyList() + activeMatches.value = emptyMap() + updateMatchedCount() + _isPlanning.value = true + _planningStep.value = SourceReplacementStep.MATCHING_TITLES + _planningProgress.value = 0 + _planningTotal.value = mangaQueue.size + _planningSourcesRemaining.value = 0 + _planningSampleSize.value = mangaQueue.size + _planningCurrentSource.value = source + planningJob = launchLoadingJob(Dispatchers.IO) { + var showManual = false + try { + val resolved = replacementPlanner.resolve(mangaQueue, source) { completed, total -> + _planningProgress.value = completed + _planningTotal.value = total + } + if (selectedSource.value == source && !isManualMode.value) { + val combinedMatches = resolved.toMutableMap() + var unmatched = mangaQueue.filterNot { it.id in combinedMatches } + updateMatchedCount() + usedSourceNames += source.name + val mixSources = replacementPlans.value.map(SourceReplacementPlan::source) + .filter { it.name !in usedSourceNames && it.name !in originalSourceNames } + .distinctBy(MangaSource::name) + _planningStep.value = SourceReplacementStep.SMART_MIX + for ((index, mixSource) in mixSources.withIndex()) { + if (unmatched.isEmpty()) { + break + } + _planningCurrentSource.value = mixSource + _planningProgress.value = 0 + _planningTotal.value = unmatched.size + _planningSourcesRemaining.value = mixSources.size - index + val mixed = replacementPlanner.resolve(unmatched, mixSource) { completed, total -> + _planningProgress.value = completed + _planningTotal.value = total + } + usedSourceNames += mixSource.name + combinedMatches.putAll(mixed) + unmatched = unmatched.filterNot { it.id in mixed } + } + activeMatches.value = combinedMatches + updateMatchedCount() + val replacements = getMatches() + _planningStep.value = SourceReplacementStep.APPLYING_MATCHES + _planningCurrentSource.value = null + _planningProgress.value = 0 + _planningTotal.value = replacements.size + // Each migration is its own transaction: a failure (or cancellation) leaves the titles + // already moved in place and the rest untouched, still in the queue. + val replacedIds = HashSet(replacements.size) + var firstError: Throwable? = null + try { + replacements.forEachIndexed { index, (seed, target) -> + runCatchingCancellable { + migrateUseCase(seed, target) + }.onSuccess { + replacedIds += seed.id + recordReplacement(target.source) + }.onFailure { e -> + if (firstError == null) firstError = e + } + _planningProgress.value = index + 1 + } + } finally { + mangaQueue = mangaQueue.filterNot { it.id in replacedIds } + } + firstError?.let { errorEvent.call(it) } + val remaining = mangaQueue.size + if (remaining > 0) { + moveToFirstRemaining() + } + _queueSize.value = remaining + activeMatches.value = emptyMap() + _isManualMode.value = remaining > 0 + _selectedSource.value = null + updateMatchedCount() + showManual = remaining > 0 + onBatchMigrated.call(BatchMigrationResult(replacedIds.size, remaining)) + if (remaining == 0) { + _replacementSummary.value = createReplacementSummary() + } + } + } finally { + _isPlanning.value = false + } + if (showManual) { + showSourceComparison() + } + } + } + + fun cancelPendingChecks() { + planningJob?.cancel() + searchJob?.cancel() + } + + private fun moveToFirstRemaining() { + currentIndex = 0 + savedStateHandle[AppRouter.KEY_INDEX] = currentIndex + val next = mangaQueue.first() + _queuePosition.value = 1 + _currentManga.value = next + manualSourceOrder = emptyList() + manualTriedSourceNames.clear() + } + + private fun updateMatchedCount() { + _matchedCount.value = if (selectedSource.value == null) 0 else getMatches().size + } + + private fun recordReplacement(source: MangaSource) { + replacementCount++ + val previous = replacementSourceUsage[source.name] + replacementSourceUsage[source.name] = SourceUsage(source, (previous?.count ?: 0) + 1) + } + + private fun createReplacementSummary() = ReplacementSummary( + replaced = replacementCount, + sources = replacementSourceUsage.values.toList(), + ) + + private fun getMatches(): List> { + val resolved = activeMatches.value + return mangaQueue.mapNotNull { seed -> + resolved[seed.id]?.let { target -> seed to target } + } + } + + private fun startSearch(query: String = manga.title) { + val seed = manga + val source = selectedSource.value ?: run { + results.value = emptyList() + return + } + searchJob?.cancel() + results.value = emptyList() + searchJob = launchLoadingJob(Dispatchers.IO) { + val reference = runCatchingCancellable { + mangaRepositoryFactory.create(seed.source).getDetails(seed) + }.getOrDefault(seed) + val candidates = alternativesUseCase.searchSource(reference, source, query) + candidates.collect { candidate -> + if (manga.id != seed.id || selectedSource.value != source) { + return@collect + } + val model = candidate.toAlternativeModel(reference) + results.update { current -> + if (current.any { it.manga.id == candidate.id }) current else current + model + } + } + } + } + + private suspend fun Manga.toAlternativeModel(reference: Manga) = MangaAlternativeModel( + mangaModel = mangaListMapper.toListModel(this, ListMode.GRID) as MangaGridModel, + referenceChapters = reference.chaptersCount(), + ) + + data class SourceChoice( + val source: MangaSource, + val matched: Int, + val total: Int, + val averageTitleSimilarity: Float, + ) { + val matchPercent: Int + get() = if (total == 0) 0 else matched * 100 / total + } + + data class PlanReady( + val matched: Int, + val total: Int, + ) + + data class MigrationResult( + val target: Manga, + val remaining: Int, + ) + + data class BatchMigrationResult( + val replaced: Int, + val remaining: Int, + ) + + data class SourceUsage( + val source: MangaSource, + val count: Int, + ) + + data class ReplacementSummary( + val replaced: Int, + val sources: List, + ) + +} + +enum class SourceReplacementStep { + COMPARING_SOURCES, + MATCHING_TITLES, + SMART_MIX, + APPLYING_MATCHES, +} + +private data class ReplacementScreenState( + val planning: Boolean, + val comparing: Boolean, + val sourceItems: List, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceScopeMenu.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceScopeMenu.kt new file mode 100644 index 0000000000..c326265ec2 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/SourceScopeMenu.kt @@ -0,0 +1,54 @@ +package io.github.landwarderer.futon.alternatives.ui + +import android.content.Context +import android.view.Menu +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScope +import io.github.landwarderer.futon.alternatives.domain.AlternativeSourceScopeOptions + +/** + * The source-scope choices shared by the alternatives screen and source replacement: every usable + * preset, then enabled and disabled sources. Kept in one place so both screens always offer the same + * list in the same order. + */ +internal object SourceScopeMenu { + + private const val ID_ENABLED = Menu.FIRST + 3000 + private const val ID_DISABLED = Menu.FIRST + 3001 + private const val ID_PRESET_FIRST = Menu.FIRST + 3100 + + /** Adds one checkable item per available scope to [menu], in [group]. */ + fun populate( + menu: Menu, + group: Int, + options: AlternativeSourceScopeOptions?, + selected: AlternativeSourceScope, + ) { + options?.presets?.forEachIndexed { index, preset -> + menu.add(group, ID_PRESET_FIRST + index, Menu.NONE, preset.title).isChecked = + selected == AlternativeSourceScope.Preset(preset.id) + } + menu.add(group, ID_ENABLED, Menu.NONE, R.string.enabled_sources).isChecked = + selected == AlternativeSourceScope.Enabled + if (options?.hasDisabledSources == true) { + menu.add(group, ID_DISABLED, Menu.NONE, R.string.disabled_sources).isChecked = + selected == AlternativeSourceScope.Disabled + } + menu.setGroupCheckable(group, true, true) + } + + /** The scope a tapped item stands for, or null when the id is not one of ours. */ + fun scopeFor(itemId: Int, options: AlternativeSourceScopeOptions?): AlternativeSourceScope? = when (itemId) { + ID_ENABLED -> AlternativeSourceScope.Enabled + ID_DISABLED -> AlternativeSourceScope.Disabled + else -> options?.presets?.getOrNull(itemId - ID_PRESET_FIRST)?.let { AlternativeSourceScope.Preset(it.id) } + } + + fun title(context: Context, scope: AlternativeSourceScope, options: AlternativeSourceScopeOptions?): String = + when (scope) { + AlternativeSourceScope.Enabled -> context.getString(R.string.enabled_sources) + AlternativeSourceScope.Disabled -> context.getString(R.string.disabled_sources) + is AlternativeSourceScope.Preset -> options?.presetTitle(scope.id) + ?: context.getString(R.string.source_presets) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverAD.kt new file mode 100644 index 0000000000..dac4e3fa2b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverAD.kt @@ -0,0 +1,33 @@ +package io.github.landwarderer.futon.alternatives.ui.covers + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.ui.list.AdapterDelegateClickListenerAdapter +import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener +import io.github.landwarderer.futon.databinding.ItemCoverAlternativeBinding +import io.github.landwarderer.futon.list.ui.model.ListModel + +fun alternativeCoverAD( + listener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCoverAlternativeBinding.inflate(inflater, parent, false) }, +) { + + itemView.setOnClickListener(AdapterDelegateClickListenerAdapter(this, listener)) + + bind { + binding.textViewSource.text = if (item.isCurrent) { + context.getString(R.string.current_cover) + } else { + item.source.getTitle(context) + } + binding.imageViewCover.setImageAsync(item.coverUrl, item.manga) + // A ring on the thumbnail ties the carousel to the big preview above it. + binding.imageViewCover.strokeWidth = if (item.isSelected) { + context.resources.getDimension(R.dimen.cover_selection_stroke) + } else { + 0f + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverModel.kt new file mode 100644 index 0000000000..02638bb83c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoverModel.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.alternatives.ui.covers + +import io.github.landwarderer.futon.list.ui.ListModelDiffCallback +import io.github.landwarderer.futon.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource + +data class AlternativeCoverModel( + val coverUrl: String, + val source: MangaSource, + val manga: Manga, + /** The cover already in use, kept first in the carousel so it can be compared against. */ + val isCurrent: Boolean, + val isSelected: Boolean, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is AlternativeCoverModel && other.coverUrl == coverUrl + } + + override fun getChangePayload(previousState: ListModel): Any? { + return if (previousState is AlternativeCoverModel && previousState.isSelected != isSelected) { + ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED + } else { + super.getChangePayload(previousState) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversActivity.kt new file mode 100644 index 0000000000..0550f62c8b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversActivity.kt @@ -0,0 +1,132 @@ +package io.github.landwarderer.futon.alternatives.ui.covers + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.MangaSource +import io.github.landwarderer.futon.core.model.getTitle +import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.ui.BaseListAdapter +import io.github.landwarderer.futon.core.ui.list.OnListItemClickListener +import io.github.landwarderer.futon.core.util.ext.consumeAll +import io.github.landwarderer.futon.core.util.ext.observe +import io.github.landwarderer.futon.databinding.ActivityAlternativeCoversBinding +import io.github.landwarderer.futon.list.ui.adapter.ListItemType +import io.github.landwarderer.futon.list.ui.model.ListModel +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource + +/** + * Covers for the same title from other sources, to be compared against the current one and picked. + */ +@AndroidEntryPoint +class AlternativeCoversActivity : + BaseActivity(), + OnListItemClickListener { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityAlternativeCoversBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + title = getString(R.string.alternative_covers) + + val adapter = BaseListAdapter() + .addDelegate(ListItemType.MANGA_GRID, alternativeCoverAD(this)) + viewBinding.recyclerView.adapter = adapter + viewBinding.buttonApply.setOnClickListener { applySelection() } + + viewModel.covers.observe(this) { covers -> + adapter.items = covers + } + combine(viewModel.covers, viewModel.selectedUrl, ::Pair).observe(this) { (covers, url) -> + val selected = covers.firstOrNull { it.coverUrl == url } + viewBinding.imageViewPreview.setImageAsync(url, selected?.manga ?: viewModel.manga) + viewBinding.textViewStatus.text = when { + selected == null -> null + selected.isCurrent -> getString(R.string.current_cover) + else -> selected.source.getTitle(this) + } + // Re-picking the cover already in use would be a no-op write. + viewBinding.buttonApply.isEnabled = selected != null && !selected.isCurrent + } + combine(viewModel.covers, viewModel.isRunning, ::Pair).observe(this) { (covers, isRunning) -> + viewBinding.progress.isVisible = isRunning + // Count only alternatives: the current cover leads the list, but is absent entirely when the + // manga has no cover of its own. + val showEmpty = covers.none { !it.isCurrent } && !isRunning + viewBinding.textViewEmpty.isVisible = showEmpty + viewBinding.recyclerView.isVisible = !showEmpty + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + viewBinding.root.updatePadding( + left = barsInsets.left, + top = barsInsets.top, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + return insets.consumeAll(typeMask) + } + + /** Tapping the carousel only previews; applying is a deliberate second step. */ + override fun onItemClick(item: AlternativeCoverModel, view: View) { + viewModel.select(item.coverUrl) + } + + private fun applySelection() { + val url = viewModel.selectedUrl.value ?: return + val selected = viewModel.covers.value.firstOrNull { it.coverUrl == url } ?: return + // Whatever is still in flight is no longer worth fetching. + viewModel.cancelSearch() + setResult( + RESULT_OK, + Intent() + .putExtra(EXTRA_COVER_URL, url) + .putExtra(EXTRA_SOURCE, selected.source.name), + ) + finish() + } + + /** A cover picked from another source, with the source it came from (for request headers). */ + data class PickedCover( + val url: String, + val source: MangaSource, + ) + + class Contract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Manga) = + Intent(context, AlternativeCoversActivity::class.java) + .putExtra(AppRouter.KEY_MANGA, ParcelableManga(input, withDescription = false)) + + override fun parseResult(resultCode: Int, intent: Intent?): PickedCover? { + if (resultCode != RESULT_OK || intent == null) { + return null + } + val url = intent.getStringExtra(EXTRA_COVER_URL) ?: return null + return PickedCover(url, MangaSource(intent.getStringExtra(EXTRA_SOURCE))) + } + } + + private companion object { + + const val EXTRA_COVER_URL = "cover_url" + const val EXTRA_SOURCE = "cover_source" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversViewModel.kt new file mode 100644 index 0000000000..62bdbd8959 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/AlternativeCoversViewModel.kt @@ -0,0 +1,167 @@ +package io.github.landwarderer.futon.alternatives.ui.covers + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowRgb565 +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.landwarderer.futon.alternatives.domain.AlternativesUseCase +import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.core.util.ext.mangaSourceExtra +import io.github.landwarderer.futon.core.util.ext.require +import io.github.landwarderer.futon.core.util.ext.toBitmapOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Collections +import javax.inject.Inject + +private const val HASH_PARALLELISM = 4 + +@HiltViewModel +class AlternativeCoversViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val alternativesUseCase: AlternativesUseCase, + @ApplicationContext private val context: Context, + private val coil: ImageLoader, +) : BaseViewModel() { + + val manga: Manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga + + /** Appended to as sources answer, so covers show up without waiting for the slowest source. */ + val covers = MutableStateFlow>(emptyList()) + + /** Which cover the big preview is showing. */ + val selectedUrl = MutableStateFlow(null) + + val isRunning = MutableStateFlow(true) + + /** Candidates are processed in parallel, so this must tolerate concurrent access. */ + private val seenKeys = Collections.synchronizedSet(HashSet()) + + /** Perceptual hashes of the covers already accepted; candidates are hashed in parallel. */ + private val acceptedHashes = ArrayList() + private val hashMutex = Mutex() + private val hashSemaphore = Semaphore(HASH_PARALLELISM) + private var searchJob: Job? = null + + init { + // The current cover leads the carousel so a candidate can be compared against it, and its key is + // seeded so no source can offer the same image back as an "alternative". + manga.coverUrl?.takeUnless { it.isEmpty() }?.let { current -> + seenKeys += current.dedupKey() + covers.value = listOf( + AlternativeCoverModel( + coverUrl = current, + source = manga.source, + manga = manga, + isCurrent = true, + isSelected = true, + ), + ) + selectedUrl.value = current + } + searchJob = launchLoadingJob(Dispatchers.Default) { + try { + // Seed the current cover's hash first, so a source offering the same artwork back is + // dropped rather than shown as an alternative to itself. + manga.coverUrl?.takeUnless { it.isEmpty() }?.let { rememberHash(it, manga.source) } + alternativesUseCase(manga, throughDisabledSources = false).collect { candidate -> + launch { onCandidate(candidate) } + } + } finally { + isRunning.value = false + } + } + } + + /** Nothing more is worth fetching once a cover is chosen, or once the screen is left. */ + fun cancelSearch() { + searchJob?.cancel() + searchJob = null + isRunning.value = false + } + + override fun onCleared() { + cancelSearch() + super.onCleared() + } + + fun select(url: String) { + if (selectedUrl.value == url) { + return + } + selectedUrl.value = url + covers.update { list -> list.map { it.copy(isSelected = it.coverUrl == url) } } + } + + private suspend fun onCandidate(candidate: Manga) { + val url = candidate.coverUrl + if (url.isNullOrEmpty() || !seenKeys.add(url.dedupKey())) { + return + } + // Cheap url check first, then the image itself: sources republish the same artwork under + // different names often enough that the url tells you almost nothing. + if (!rememberHash(url, candidate.source)) { + return + } + covers.update { current -> + current + AlternativeCoverModel( + coverUrl = url, + source = candidate.source, + manga = candidate, + isCurrent = false, + isSelected = false, + ) + } + } + + /** + * Hashes a cover and records it, returning `false` when it looks like one already accepted. + * + * A cover that cannot be decoded is kept: a duplicate is a smaller problem than a missing option. + */ + private suspend fun rememberHash(url: String, source: MangaSource): Boolean { + val hash = hashSemaphore.withPermit { hashOf(url, source) } ?: return true + return hashMutex.withLock { + if (acceptedHashes.any { CoverHash.isSame(it, hash) }) { + false + } else { + acceptedHashes.add(hash) + true + } + } + } + + private suspend fun hashOf(url: String, source: MangaSource): Long? = runCatchingCancellable { + val request = ImageRequest.Builder(context) + .data(url) + .size(CoverHash.SAMPLE_WIDTH, CoverHash.SAMPLE_HEIGHT) + .allowRgb565(false) + .mangaSourceExtra(source) + .build() + coil.execute(request).toBitmapOrNull()?.let(CoverHash::of) + }.getOrNull() + + /** + * Sources routinely mirror the same artwork under different urls, so comparing whole urls dedupes + * almost nothing. The filename is a decent proxy for "same image" without decoding anything. + */ + private fun String.dedupKey(): String = substringBefore('?') + .substringAfterLast('/') + .lowercase() + .ifEmpty { this } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/CoverHash.kt b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/CoverHash.kt new file mode 100644 index 0000000000..062f8036cf --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/alternatives/ui/covers/CoverHash.kt @@ -0,0 +1,160 @@ +package io.github.landwarderer.futon.alternatives.ui.covers + +import android.graphics.Bitmap +import androidx.core.graphics.get +import kotlin.math.cos + +/** + * Perceptual hash of a cover, for spotting the same artwork republished by another source. + * + * Sources re-encode, pad and re-scale the same image, so url or byte equality catches almost none of + * the duplicates people actually see. Three steps make the comparison survive that: + * + * 1. trim near-uniform edges, which removes letterboxing and padding; + * 2. centre-crop to a fixed aspect, so covers stored at different proportions still line up; + * 3. hash the low-frequency DCT coefficients, which describe overall structure and ignore sharpening, + * compression and brightness. + * + * Known limit: a genuinely tighter crop of the same art still scores far apart. + */ +object CoverHash { + + /** Source bitmap size to hash. Big enough that trimming edges leaves real detail behind. */ + const val SAMPLE_WIDTH = 64 + const val SAMPLE_HEIGHT = 96 + + /** Differing bits (out of 63) below which two covers are treated as the same image. */ + const val MAX_DISTANCE = 12 + + private const val DCT_SIZE = 32 + private const val HASH_SIZE = 8 + private const val UNIFORM_TOLERANCE = 12 + private const val TARGET_ASPECT = 2f / 3f + + fun of(bitmap: Bitmap): Long = ofLuma(bitmap.toLuma()) + + /** + * Hashes a luma matrix (rows of 0..255 values). Pure, so it can be unit tested without Android. + */ + fun ofLuma(luma: Array): Long { + require(luma.isNotEmpty() && luma[0].isNotEmpty()) { "Empty image" } + val trimmed = luma.autoCrop().centerToAspect() + val matrix = trimmed.scaleTo(DCT_SIZE) + val dct = matrix.dct2d() + // Low-frequency block only, minus the DC term: DC is average brightness, which we want ignored. + val values = DoubleArray(HASH_SIZE * HASH_SIZE - 1) + var i = 0 + for (y in 0 until HASH_SIZE) { + for (x in 0 until HASH_SIZE) { + if (x != 0 || y != 0) { + values[i++] = dct[y][x] + } + } + } + val median = values.sortedArray()[values.size / 2] + var hash = 0L + values.forEachIndexed { index, value -> + if (value > median) { + hash = hash or (1L shl index) + } + } + return hash + } + + fun distance(a: Long, b: Long): Int = java.lang.Long.bitCount(a xor b) + + fun isSame(a: Long, b: Long): Boolean = distance(a, b) <= MAX_DISTANCE + + private fun Bitmap.toLuma(): Array { + // getPixel throws on a hardware bitmap, and a fixed-size copy is needed anyway. + val software = if (config == Bitmap.Config.HARDWARE) copy(Bitmap.Config.ARGB_8888, false) ?: this else this + val scaled = Bitmap.createScaledBitmap(software, SAMPLE_WIDTH, SAMPLE_HEIGHT, true) + val out = Array(SAMPLE_HEIGHT) { IntArray(SAMPLE_WIDTH) } + for (y in 0 until SAMPLE_HEIGHT) { + for (x in 0 until SAMPLE_WIDTH) { + val pixel = scaled[x, y] + val r = (pixel shr 16) and 0xFF + val g = (pixel shr 8) and 0xFF + val b = pixel and 0xFF + // Rec. 601 luma in integer maths; float precision is meaningless at this size. + out[y][x] = (r * 299 + g * 587 + b * 114) / 1000 + } + } + if (scaled !== software) scaled.recycle() + if (software !== this) software.recycle() + return out + } + + /** Drops near-uniform edge rows and columns, so padding and letterboxing stop counting as content. */ + private fun Array.autoCrop(): Array { + var top = 0 + var bottom = size - 1 + while (top < bottom && isRowUniform(top)) top++ + while (bottom > top && isRowUniform(bottom)) bottom-- + var left = 0 + var right = this[0].size - 1 + while (left < right && isColumnUniform(left, top, bottom)) left++ + while (right > left && isColumnUniform(right, top, bottom)) right-- + return Array(bottom - top + 1) { y -> this[top + y].copyOfRange(left, right + 1) } + } + + private fun Array.centerToAspect(): Array { + val height = size + val width = this[0].size + val wantWidth = minOf(width, (height * TARGET_ASPECT).toInt()).coerceAtLeast(1) + val wantHeight = minOf(height, (wantWidth / TARGET_ASPECT).toInt()).coerceAtLeast(1) + val x0 = (width - wantWidth) / 2 + val y0 = (height - wantHeight) / 2 + return Array(wantHeight) { y -> this[y0 + y].copyOfRange(x0, x0 + wantWidth) } + } + + private fun Array.scaleTo(size: Int): Array { + val height = this.size + val width = this[0].size + return Array(size) { y -> + DoubleArray(size) { x -> + this[y * height / size][x * width / size].toDouble() + } + } + } + + private fun Array.dct2d(): Array { + val n = size + val rows = Array(n) { y -> this[y].dct1d() } + val out = Array(n) { DoubleArray(n) } + val column = DoubleArray(n) + for (x in 0 until n) { + for (y in 0 until n) column[y] = rows[y][x] + val transformed = column.dct1d() + for (y in 0 until n) out[y][x] = transformed[y] + } + return out + } + + private fun DoubleArray.dct1d(): DoubleArray { + val n = size + return DoubleArray(n) { k -> + var sum = 0.0 + for (i in 0 until n) { + sum += this[i] * cos(Math.PI * (i + 0.5) * k / n) + } + sum + } + } + + private fun Array.isRowUniform(y: Int): Boolean { + val row = this[y] + return row.max() - row.min() <= UNIFORM_TOLERANCE + } + + private fun Array.isColumnUniform(x: Int, top: Int, bottom: Int): Boolean { + var min = Int.MAX_VALUE + var max = Int.MIN_VALUE + for (y in top..bottom) { + val v = this[y][x] + if (v < min) min = v + if (v > max) max = v + } + return max - min <= UNIFORM_TOLERANCE + } +} 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 { 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 3782d29c8c..212e48823f 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,38 +6,67 @@ 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.withTimeoutOrNull 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 +import io.github.landwarderer.futon.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.MangaSource 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 @@ -45,15 +74,41 @@ 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 val needsInterception = shouldUseInterception(source, repository) @@ -74,14 +129,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) } @@ -103,32 +176,207 @@ 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 + var lastState: String? = null + 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() + } + } + val state = probeChallengeState() + if (state != lastState) { + Log.d(TAG, "Challenge state: $state") + lastState = state + } + if (state == 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() + } + + /** A WebView busy navigating may never answer [evaluateJavascript]: count that as still waiting. */ + private suspend fun probeChallengeState(): String = withTimeoutOrNull(PROBE_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + viewBinding.webView.evaluateJavascript(CF_STATE_JS) { raw -> + if (cont.isActive) { + cont.resume(raw?.removeSurrounding("\"") ?: CF_STATE_WAIT) + } + } + } + } ?: CF_STATE_WAIT + + private fun finishSuccess() { + if (pendingResult == RESULT_OK) return pendingResult = RESULT_OK - lifecycleScope.launch { - val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) - if (source != null) { + resolveJob?.cancel() + rememberAlignedUserAgent() + // 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) { + // Not tied to this Activity: discard() waits for the captcha lock and rebuilds the remaining + // captcha notifications, favicons included. Closing must not wait for that, and closing (or the + // user tapping X meanwhile) must not cancel it, or the solve is never reported. + processLifecycleScope.launch { runCatchingCancellable { captchaHandler.discard(MangaSource(source)) }.onFailure { - it.printStackTraceDebug("CloudFlareActivity::onCheckPassed") + it.printStackTraceDebug("CloudFlareActivity::finishSuccess") } } - finishAfterTransition() } + finishAfterTransition() } override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { @@ -138,23 +386,65 @@ 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) } } + /** + * 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 +483,23 @@ 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 PROBE_TIMEOUT_MS = 2_000L + 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..05f90f91cb 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,8 +28,10 @@ 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.JxlImageDecoder import io.github.landwarderer.futon.core.image.CbzFetcher import io.github.landwarderer.futon.core.image.MangaSourceHeaderInterceptor import io.github.landwarderer.futon.core.network.MangaHttpClient @@ -41,6 +43,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 @@ -140,6 +143,7 @@ interface AppModule { add(SvgDecoder.Factory()) add(CbzFetcher.Factory()) add(AvifImageDecoder.Factory()) + add(JxlImageDecoder.Factory()) add(faviconFetcherFactory) add(MangaPageKeyer()) add(pageFetcherFactory) @@ -174,11 +178,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/db/ContentTypeCondition.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/ContentTypeCondition.kt new file mode 100644 index 0000000000..1342aeba6a --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/ContentTypeCondition.kt @@ -0,0 +1,54 @@ +package io.github.landwarderer.futon.core.db + +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource + +/** + * SQL predicate selecting library manga of the given [contentType], for a query joined with `manga`. + * + * Built-in parsers declare a content type, so their manga are selected by source name. Mihon + * extensions do not: Futon's wrapper reports every Mihon source as plain manga (or hentai), so a + * source-based lookup would put every Mihon webtoon under "Manga" and hide it from "Manhwa". For + * those sources the manga's own genre tags are used instead - many sites tag their entries with + * "Manhwa" or "Manhua" - and a Mihon manga carrying no such tag is only listed when no content type + * is selected. + */ +internal fun contentTypeCondition( + contentType: ContentType, + parserSources: Collection = MangaParserSource.entries, +): String { + val conditions = ArrayList(2) + val sourceNames = parserSources.filter { it.contentType == contentType } + if (sourceNames.isNotEmpty()) { + conditions += sourceNames.joinToString( + prefix = "manga.source IN (", + postfix = ")", + transform = { sqlQuote(it.name) }, + ) + } + val tagTitles = contentTypeTagTitles(contentType) + if (tagTitles.isNotEmpty()) { + conditions += "($MIHON_SOURCE_CONDITION AND EXISTS(SELECT 1 FROM manga_tags " + + "INNER JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE manga_tags.manga_id = manga.manga_id " + + "AND LOWER(tags.title) IN (${tagTitles.joinToString { sqlQuote(it) }})))" + } + return when (conditions.size) { + 0 -> "0" + 1 -> conditions.single() + else -> conditions.joinToString(separator = " OR ", prefix = "(", postfix = ")") + } +} + +/** Lower-case genre tag titles that mark a manga from a source without a declared content type. */ +internal fun contentTypeTagTitles(contentType: ContentType): List = when (contentType) { + ContentType.MANGA -> listOf("manga") + ContentType.MANHWA -> listOf("manhwa") + ContentType.MANHUA -> listOf("manhua") + else -> emptyList() +} + +/** Matches the names Mihon sources are stored under (see `MangaSource(name)`). */ +private const val MIHON_SOURCE_CONDITION = "(manga.source LIKE 'MIHON\\_%' ESCAPE '\\' OR manga.source LIKE 'mihon:%')" + +/** Quotes a constant as an SQL string literal. Only used for values built into the app. */ +internal fun sqlQuote(value: String): String = "'" + value.replace("'", "''") + "'" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/LibrarySearchQuery.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/LibrarySearchQuery.kt new file mode 100644 index 0000000000..2ddf159255 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/LibrarySearchQuery.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.core.db + +/** + * Text filtering a library screen (History, Favourites), together with the source names it was + * resolved to by [SourceSearchFilter]. + */ +data class LibrarySearchQuery( + val text: String, + val sourceNames: List = emptyList(), +) { + + fun isEmpty() = text.isEmpty() + + companion object { + + val EMPTY = LibrarySearchQuery("") + } +} + +/** An SQL fragment with the arguments bound to its `?` placeholders, in order. */ +class SqlCondition( + val sql: String, + val args: List, +) + +/** `%text%` with LIKE wildcards in [text] escaped, for use with `ESCAPE '\'`. */ +fun likeContainsPattern(text: String): String = buildString(text.length + 2) { + append('%') + for (c in text) { + if (c == '\\' || c == '%' || c == '_') { + append('\\') + } + append(c) + } + append('%') +} + +/** + * Matches anything that identifies an entry on a library screen: its title, the source it came + * from, and the favourites list it is filed under. All user text is bound, never inlined. + * + * @param categoryMatch a predicate on the entry's favourites lists with a single `?` placeholder + * for the LIKE pattern, e.g. `EXISTS(... c.title LIKE ? ESCAPE '\')` + */ +fun librarySearchCondition(query: LibrarySearchQuery, categoryMatch: String): SqlCondition { + require(categoryMatch.count { it == '?' } == 1) { "categoryMatch must have exactly one placeholder" } + val pattern = likeContainsPattern(query.text) + val args = ArrayList(3 + query.sourceNames.size) + val sql = StringBuilder("(manga.title LIKE ? ESCAPE '\\' OR manga.alt_title LIKE ? ESCAPE '\\'") + args += pattern + args += pattern + if (query.sourceNames.isNotEmpty()) { + query.sourceNames.joinTo(sql, prefix = " OR manga.source IN (", postfix = ")") { "?" } + args.addAll(query.sourceNames) + } + sql.append(" OR ").append(categoryMatch).append(')') + args += pattern + return SqlCondition(sql.toString(), args) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt index 8deadeef22..a39dd96f33 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaDatabase.kt @@ -47,6 +47,7 @@ import io.github.landwarderer.futon.core.db.migrations.Migration27To28 import io.github.landwarderer.futon.core.db.migrations.Migration28To29 import io.github.landwarderer.futon.core.db.migrations.Migration29To30 import io.github.landwarderer.futon.core.db.migrations.Migration2To3 +import io.github.landwarderer.futon.core.db.migrations.Migration30To31 import io.github.landwarderer.futon.core.db.migrations.Migration3To4 import io.github.landwarderer.futon.core.db.migrations.Migration4To5 import io.github.landwarderer.futon.core.db.migrations.Migration5To6 @@ -59,6 +60,8 @@ import io.github.landwarderer.futon.download.data.dao.DownloadQueueDao import io.github.landwarderer.futon.download.data.dao.SmartDownloadDao import io.github.landwarderer.futon.download.data.entity.DownloadQueueEntity import io.github.landwarderer.futon.download.data.entity.SmartDownloadEntity +import io.github.landwarderer.futon.explore.data.SourcePresetEntity +import io.github.landwarderer.futon.explore.data.SourcePresetsDao import io.github.landwarderer.futon.favourites.data.FavouriteCategoriesDao import io.github.landwarderer.futon.favourites.data.FavouriteCategoryEntity import io.github.landwarderer.futon.favourites.data.FavouriteEntity @@ -81,7 +84,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -const val DATABASE_VERSION = 30 +const val DATABASE_VERSION = 31 @Database( entities = [ @@ -89,7 +92,7 @@ const val DATABASE_VERSION = 30 FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ExternalExtensionRepoEntity::class, - DownloadQueueEntity::class, SmartDownloadEntity::class, + DownloadQueueEntity::class, SmartDownloadEntity::class, SourcePresetEntity::class, ], version = DATABASE_VERSION, ) @@ -131,6 +134,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract fun getDownloadQueueDao(): DownloadQueueDao abstract fun getSmartDownloadDao(): SmartDownloadDao + + abstract fun getSourcePresetsDao(): SourcePresetsDao } fun getDatabaseMigrations(context: Context): Array = arrayOf( @@ -163,7 +168,8 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration26To27(), Migration27To28(), Migration28To29(), - Migration29To30(), + Migration29To30(), + Migration30To31(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaQueryBuilder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaQueryBuilder.kt index 8b7f6a79a4..6dd4f0f3bf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaQueryBuilder.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/MangaQueryBuilder.kt @@ -11,6 +11,7 @@ class MangaQueryBuilder( private var filterOptions: Collection = emptyList() private var whereConditions = LinkedList() + private val whereArgs = ArrayList() private var orderBy: String? = null private var groupBy: String? = null private var extraJoins: String? = null @@ -24,6 +25,12 @@ class MangaQueryBuilder( whereConditions.add(condition) } + /** A condition with bound arguments; they are bound in the order the conditions were added. */ + fun where(condition: SqlCondition) = apply { + whereConditions.add(condition.sql) + whereArgs.addAll(condition.args) + } + fun orderBy(orderBy: String?) = apply { this@MangaQueryBuilder.orderBy = orderBy } @@ -97,7 +104,7 @@ class MangaQueryBuilder( append(" LIMIT ") append(limit) } - }.let { SimpleSQLiteQuery(it) } + }.let { SimpleSQLiteQuery(it, whereArgs.toTypedArray()) } private fun getConditionOrThrow(option: ListFilterOption): String = when (option) { is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/SourceSearchFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/SourceSearchFilter.kt new file mode 100644 index 0000000000..1ad4a0d38d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/SourceSearchFilter.kt @@ -0,0 +1,88 @@ +package io.github.landwarderer.futon.core.db + +import dagger.Reusable +import io.github.landwarderer.futon.mihon.MihonExtensionManager +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject + +/** + * Resolves search text to the source names stored in `manga.source`. + * + * The column holds identifiers (`MANGAFIRE_EN` for a built-in parser, `MIHON_` for a Mihon + * extension) while everything the user sees - the search bar, the suggestion row - carries the + * display title ("MangaFire (English)", "Asura Scans"). The two are not derivable from one another, + * so the text is matched against the real source list and the exact names are emitted. + * + * Mihon titles come from the installed extensions and, for extensions that have since been + * uninstalled, from the titles persisted in the `sources` table. + */ +@Reusable +class SourceSearchFilter @Inject constructor( + private val db: MangaDatabase, + private val mihonExtensionManager: MihonExtensionManager, +) { + + /** The screen filter for [text], with the sources it names resolved. */ + suspend fun resolve(text: String): LibrarySearchQuery = if (text.isEmpty()) { + LibrarySearchQuery.EMPTY + } else { + LibrarySearchQuery(text, findSourceNames(text)) + } + + suspend fun findSourceNames(query: String): List { + if (query.trim().length < MIN_QUERY_LENGTH) { + return emptyList() + } + val candidates = ArrayList(MangaParserSource.entries.size + 64) + MangaParserSource.entries.mapTo(candidates) { SourceCandidate(it.name, it.title) } + mihonExtensionManager.getMihonMangaSources().mapTo(candidates) { SourceCandidate(it.name, it.displayName) } + runCatchingCancellable { + db.getSourcesDao().findAll() + }.getOrNull()?.forEach { entity -> + val title = entity.title + if (!title.isNullOrEmpty() && isMihonSourceName(entity.source)) { + candidates += SourceCandidate(entity.source, title) + } + } + return matchSourceNames(query, candidates) + } + + data class SourceCandidate( + val name: String, + val title: String, + ) + + companion object { + + /** Two characters is where a contains match stops selecting most of the source list. */ + private const val MIN_QUERY_LENGTH = 2 + + private const val MAX_SOURCES = 24 + + fun isMihonSourceName(name: String) = name.startsWith("MIHON_") || name.startsWith("mihon:") + + /** + * An exact title match wins, which is what a tapped suggestion produces and what keeps sibling + * locales apart - picking "MangaFire (English)" must not also select `MANGAFIRE_PTBR`. Typed + * text falls back to a contains match, where selecting every MangaFire locale is the + * reasonable reading. + */ + fun matchSourceNames(query: String, candidates: Collection): List { + val q = query.trim() + if (q.length < MIN_QUERY_LENGTH) { + return emptyList() + } + val exact = candidates.filter { it.title.equals(q, ignoreCase = true) } + if (exact.isNotEmpty()) { + return exact.mapTo(LinkedHashSet()) { it.name }.toList() + } + return candidates.asSequence() + .filter { it.title.contains(q, ignoreCase = true) } + .map { it.name } + .distinct() + .take(MAX_SOURCES) + .toList() + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt index 5b7602df7d..d0158e56a5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/Tables.kt @@ -11,3 +11,4 @@ const val TABLE_CHAPTERS = "chapters" const val TABLE_PREFERENCES = "preferences" const val TABLE_DOWNLOAD_QUEUE = "download_queue" const val TABLE_SMART_DOWNLOADS = "smart_downloads" +const val TABLE_SOURCE_PRESETS = "source_presets" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ChaptersDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ChaptersDao.kt index e3ff8079d0..e5694c8b64 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ChaptersDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/ChaptersDao.kt @@ -13,6 +13,9 @@ abstract class ChaptersDao { @Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC") abstract suspend fun findAll(mangaId: Long): List + @Query("SELECT * FROM chapters WHERE manga_id = :mangaId AND chapter_id = :chapterId") + abstract suspend fun find(mangaId: Long, chapterId: Long): ChapterEntity? + @Query("DELETE FROM chapters WHERE manga_id = :mangaId") abstract suspend fun deleteAll(mangaId: Long) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/MangaDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/MangaDao.kt index aef1d31f2b..4b0ea84f7c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/MangaDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/dao/MangaDao.kt @@ -31,6 +31,10 @@ abstract class MangaDao { @Query("SELECT * FROM manga WHERE source = :source") abstract suspend fun findAllBySource(source: String): List + @Transaction + @Query("SELECT * FROM manga WHERE source = :source AND manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0 UNION SELECT manga_id FROM history WHERE deleted_at = 0)") + abstract suspend fun findLibraryBySource(source: String): List + @Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit") abstract suspend fun findAuthors(query: String, limit: Int): List 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/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/core/db/migrations/Migration30To31.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/migrations/Migration30To31.kt new file mode 100644 index 0000000000..717223cb95 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/db/migrations/Migration30To31.kt @@ -0,0 +1,21 @@ +package io.github.landwarderer.futon.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration30To31 : Migration(30, 31) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `source_presets` ( + `preset_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` TEXT NOT NULL, + `languages` TEXT NOT NULL, + `sources` TEXT NOT NULL, + `created_at` INTEGER NOT NULL, + `sort_key` INTEGER NOT NULL, + `deleted_at` INTEGER NOT NULL DEFAULT 0 + )""", + ) + } +} 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 12f8953854..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 @@ -54,6 +53,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 @@ -67,17 +69,30 @@ 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() 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() + + /** + * 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) } override fun onError(request: ImageRequest, result: ErrorResult) { @@ -86,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, + ) } } } @@ -103,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 { @@ -268,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, + ) } } } @@ -287,6 +311,6 @@ 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 RESOLVE_TIMEOUT = 20_000L + private const val SOLVED_BUFFER_CAPACITY = 8 } } 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/image/BitmapDecoderCompat.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/BitmapDecoderCompat.kt index 54dbc9050f..962e6c7bac 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/image/BitmapDecoderCompat.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/BitmapDecoderCompat.kt @@ -7,6 +7,9 @@ import android.graphics.ImageDecoder import android.os.Build import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap +import com.awxkee.jxlcoder.JxlCoder +import com.awxkee.jxlcoder.PreferredColorConfig +import com.awxkee.jxlcoder.ScaleMode import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import io.github.landwarderer.futon.core.util.MimeTypes import io.github.landwarderer.futon.core.util.ext.MimeType @@ -15,6 +18,9 @@ import io.github.landwarderer.futon.core.util.ext.readByteBuffer import io.github.landwarderer.futon.core.util.ext.toByteBuffer import io.github.landwarderer.futon.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import okio.BufferedSource +import okio.ByteString +import okio.ByteString.Companion.toByteString import okio.IOException import okio.buffer import okio.source @@ -28,14 +34,31 @@ import java.nio.ByteBuffer object BitmapDecoderCompat { private const val FORMAT_AVIF = "avif" + private const val FORMAT_JXL = "jxl" + + /** A bare JPEG XL codestream. */ + private val JXL_CODESTREAM = byteArrayOf(0xFF.toByte(), 0x0A).toByteString() + + /** The signature box that opens a JPEG XL in its ISOBMFF container. */ + private val JXL_CONTAINER = byteArrayOf( + 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87.toByte(), 0x0A, + ).toByteString() + + private const val JXL_HEADER_SIZE = 12 @Blocking - fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) { - FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) } - else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) - } else { - checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format) + fun decode(file: File): Bitmap { + // By content, not name: sites serve JPEG XL under .jpg URLs to browsers that accept it + if (isJxl(file)) { + return decodeJxl(file.readBytes()) + } + return when (val format = probeMimeType(file)?.subtype) { + FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) } + else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) + } else { + checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format) + } } } @@ -45,19 +68,55 @@ object BitmapDecoderCompat { if (format == FORMAT_AVIF) { return decodeAvif(stream.toByteBuffer()) } + if (format == FORMAT_JXL) { + return decodeJxl(stream.readBytes(), isMutable) + } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + val input = if (stream.markSupported()) stream else stream.buffered() + input.mark(JXL_HEADER_SIZE) + val header = ByteArray(JXL_HEADER_SIZE) + val headerSize = input.readNBytesCompat(header) + input.reset() + if (isJxl(header.toByteString(0, headerSize))) { + return decodeJxl(input.readBytes(), isMutable) + } val opts = BitmapFactory.Options() opts.inMutable = isMutable - return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format) + return checkBitmapNotNull(BitmapFactory.decodeStream(input, null, opts), format) } val byteBuffer = stream.toByteBuffer() - return if (AvifDecoder.isAvifImage(byteBuffer)) { - decodeAvif(byteBuffer) - } else { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable)) + return when { + AvifDecoder.isAvifImage(byteBuffer) -> decodeAvif(byteBuffer) + isJxl(byteBuffer) -> decodeJxl(ByteArray(byteBuffer.remaining()).also { byteBuffer.duplicate().get(it) }, isMutable) + else -> ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable)) } } + /** Checks the signature without consuming it, which is what Coil's decoder factories need. */ + fun isJxl(source: BufferedSource): Boolean = + source.rangeEquals(0, JXL_CONTAINER) || source.rangeEquals(0, JXL_CODESTREAM) + + @Blocking + fun isJxl(file: File): Boolean = runCatchingCancellable { + file.source().buffer().use { isJxl(it) } + }.getOrDefault(false) + + /** + * Free memory to ask for before decoding [file]. + * + * Twice the file size is the rule for the formats this was written for. A JPEG XL is often a tenth + * of the PNG it decodes to, so for one that guess is read from the header instead. + */ + @Blocking + fun estimateDecodedSize(file: File): Long { + val guess = file.length() * 2 + if (!isJxl(file) || !JxlSupport.isAvailable) { + return guess + } + val size = runCatchingCancellable { JxlCoder.getSize(file.readBytes()) }.getOrNull() ?: return guess + return maxOf(guess, size.width.toLong() * size.height * 4) + } + @Blocking fun createRegionDecoder(inoutStream: InputStream): BitmapRegionDecoder? = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -67,12 +126,15 @@ object BitmapDecoderCompat { BitmapRegionDecoder.newInstance(inoutStream, false) } } catch (e: IOException) { - e.printStackTraceDebug("DecoderConfigListener::createRegionDecoder") + e.printStackTraceDebug() null } @Blocking fun probeMimeType(file: File): MimeType? { + if (isJxl(file)) { + return MimeTypes.JXL + } return MimeTypes.probeMimeType(file) ?: detectBitmapType(file) } @@ -106,6 +168,40 @@ object BitmapDecoderCompat { return bitmap } + private fun decodeJxl(bytes: ByteArray, isMutable: Boolean = false): Bitmap { + if (!JxlSupport.isAvailable) { + throw ImageDecodeException(null, FORMAT_JXL, "JPEG XL is not supported on this device") + } + // 8-bit RGBA whatever the source depth: a HARDWARE or F16 bitmap cannot be re-encoded to PNG + // by the reader's conversion fallback the same way + val bitmap = JxlCoder.decode(bytes, PreferredColorConfig.RGBA_8888, ScaleMode.FIT) + if (!isMutable || bitmap.isMutable) { + return bitmap + } + return bitmap.copy(Bitmap.Config.ARGB_8888, true).also { bitmap.recycle() } + } + + private fun isJxl(header: ByteString): Boolean = + header.startsWith(JXL_CONTAINER) || header.startsWith(JXL_CODESTREAM) + + private fun isJxl(buffer: ByteBuffer): Boolean { + val size = minOf(buffer.remaining(), JXL_HEADER_SIZE) + val header = ByteArray(size).also { buffer.duplicate().get(it) } + return isJxl(header.toByteString()) + } + + private fun InputStream.readNBytesCompat(buffer: ByteArray): Int { + var total = 0 + while (total < buffer.size) { + val read = read(buffer, total, buffer.size - total) + if (read < 0) { + break + } + total += read + } + return total + } + @RequiresApi(Build.VERSION_CODES.P) private class DecoderConfigListener( private val isMutable: Boolean, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlImageDecoder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlImageDecoder.kt new file mode 100644 index 0000000000..ab42b89608 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlImageDecoder.kt @@ -0,0 +1,95 @@ +package io.github.landwarderer.futon.core.image + +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.DecodeUtils +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import coil3.request.maxBitmapSize +import coil3.util.component1 +import coil3.util.component2 +import com.awxkee.jxlcoder.JxlCoder +import com.awxkee.jxlcoder.JxlResizeFilter +import com.awxkee.jxlcoder.PreferredColorConfig +import com.awxkee.jxlcoder.ScaleMode +import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException +import kotlinx.coroutines.runInterruptible +import io.github.landwarderer.futon.core.util.MimeTypes + +/** + * JPEG XL for Coil, which Android cannot decode itself. + * + * Covers, thumbnails and page previews only ever need a small bitmap, so the image is decoded straight + * to the requested size rather than in full and scaled down - a webtoon strip can be tens of + * thousands of pixels tall. + */ +class JxlImageDecoder( + private val source: ImageSource, + private val options: Options, +) : Decoder { + + override suspend fun decode(): DecodeResult = runInterruptible { + val bytes = source.source().readByteArray() + val size = JxlCoder.getSize(bytes) ?: throw ImageDecodeException( + uri = source.fileOrNull()?.toString(), + format = FORMAT, + message = "Cannot read the size of a JPEG XL image", + ) + val (dstWidth, dstHeight) = DecodeUtils.computeDstSize( + srcWidth = size.width, + srcHeight = size.height, + targetSize = options.size, + scale = options.scale, + maxSize = options.maxBitmapSize, + ) + if (dstWidth < size.width || dstHeight < size.height) { + DecodeResult( + image = JxlCoder.decodeSampled( + bytes, + dstWidth, + dstHeight, + PreferredColorConfig.RGBA_8888, + ScaleMode.FIT, + JxlResizeFilter.BILINEAR, + ).asImage(), + isSampled = true, + ) + } else { + DecodeResult( + image = JxlCoder.decode(bytes, PreferredColorConfig.RGBA_8888, ScaleMode.FIT).asImage(), + isSampled = false, + ) + } + } + + class Factory : Decoder.Factory { + + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader + ): Decoder? = if (isApplicable(result) && JxlSupport.isAvailable) { + JxlImageDecoder(result.source, options) + } else { + null + } + + override fun equals(other: Any?) = other is Factory + + override fun hashCode() = javaClass.hashCode() + + // By content as well as by type: page caches have no extension, and sites serve JPEG XL + // under the URL of the JPEG it replaced + private fun isApplicable(result: SourceFetchResult): Boolean { + return result.mimeType == MimeTypes.JXL.toString() || BitmapDecoderCompat.isJxl(result.source.source()) + } + } + + private companion object { + + const val FORMAT = "jxl" + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlSupport.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlSupport.kt new file mode 100644 index 0000000000..8eab314886 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/image/JxlSupport.kt @@ -0,0 +1,26 @@ +package io.github.landwarderer.futon.core.image + +import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug + +/** + * Whether JPEG XL can be decoded on this device. + * + * jxl-coder ships libraries for armeabi-v7a, arm64-v8a, x86 and x86_64. Should loading still fail - an + * unexpected ABI, a translation layer missing something, a stripped install - this keeps it to one image + * failing. It has to be asked before the first `JxlCoder` call: its class initializer loads the library, + * and a failure there is an Error that would take the image pipeline down rather than fail one image. + * + * Only asked once an image has been recognised as JPEG XL, so nobody else pays for loading it. + */ +object JxlSupport { + + val isAvailable: Boolean by lazy { + try { + System.loadLibrary("jxlcoder") + true + } catch (e: LinkageError) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/Manga.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/Manga.kt index ac25c98273..1b32402b93 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/Manga.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/Manga.kt @@ -125,6 +125,23 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? { return groups.maxByOrNull { it.value.size }?.key } +/** + * Finds [previous], a chapter of an earlier load of this list, again after the source gave it a new id. + * + * Some sources derive chapter ids from data that changes between two loads of the same chapter: a mirror host + * in the chapter link, a scanlation group name, a re-upload. The same chapter is the one with the same link in + * the same branch, else the only one with the same volume and number in that branch, else the only one with the + * same link. Returns null rather than a guess when that is ambiguous. + */ +fun List.findSameChapter(previous: MangaChapter): MangaChapter? { + firstOrNull { it.url == previous.url && it.branch == previous.branch }?.let { return it } + singleOrNull { + it.branch == previous.branch && it.volume == previous.volume && it.number == previous.number && + (previous.number > 0f || it.title == previous.title) // 0 often means "no number" + }?.let { return it } + return singleOrNull { it.url == previous.url } +} + val Manga.isLocal: Boolean get() = source == LocalMangaSource || source == LocalNovelSource diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSourceTraits.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSourceTraits.kt new file mode 100644 index 0000000000..0cac6deb36 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/model/MangaSourceTraits.kt @@ -0,0 +1,59 @@ +package io.github.landwarderer.futon.core.model + +import android.content.Context +import io.github.landwarderer.futon.core.util.ext.getDisplayName +import io.github.landwarderer.futon.core.util.ext.toLocaleOrNull +import io.github.landwarderer.futon.mihon.extensions.runtime.getExternalExtensionLanguageDisplayName +import io.github.landwarderer.futon.mihon.model.MihonMangaSource +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.util.Locale +import io.github.landwarderer.futon.mihon.parsers.model.ContentType as MihonContentType + +private val MULTI_LANGUAGE_CODES = setOf("all", "other", "multi") + +/** + * The language code as the source declares it (`en`, `pt`, `pt-BR`...), for both built-in parsers and + * Mihon extensions, or null when the source is multilingual or does not say. + */ +val MangaSource.languageCode: String? + get() = when (val source = unwrap()) { + is MangaParserSource -> source.locale + is MihonMangaSource -> source.language + else -> null + }?.trim()?.takeUnless { it.isEmpty() || it.lowercase(Locale.ROOT) in MULTI_LANGUAGE_CODES } + +/** The bare language of [languageCode], lowercased (`pt-BR` -> `pt`), for comparing sources. */ +val MangaSource.primaryLanguage: String? + get() = languageCode?.let(::primaryLanguageOf) + +fun primaryLanguageOf(code: String): String = code.substringBefore('-').substringBefore('_').lowercase(Locale.ROOT) + +/** The content type of a built-in parser or of a Mihon extension, mapped onto the parsers library type. */ +val MangaSource.contentTypeCompat: ContentType? + get() = when (val source = unwrap()) { + is MangaParserSource -> source.contentType + is MihonMangaSource -> source.contentType.toContentType() + else -> null + } + +fun MihonContentType.toContentType(): ContentType = when (this) { + MihonContentType.MANGA -> ContentType.MANGA + MihonContentType.MANHWA -> ContentType.MANHWA + MihonContentType.MANHUA -> ContentType.MANHUA + MihonContentType.HENTAI_MANGA, MihonContentType.HENTAI_NOVEL, MihonContentType.HENTAI_VIDEO -> ContentType.HENTAI + MihonContentType.COMICS -> ContentType.COMICS + MihonContentType.VIDEO -> ContentType.OTHER + MihonContentType.NOVEL -> ContentType.NOVEL + MihonContentType.ONE_SHOT -> ContentType.ONE_SHOT + MihonContentType.DOUJINSHI -> ContentType.DOUJINSHI + MihonContentType.IMAGE_SET -> ContentType.IMAGE_SET + MihonContentType.ARTIST_CG -> ContentType.ARTIST_CG + MihonContentType.GAME_CG -> ContentType.GAME_CG + MihonContentType.OTHER -> ContentType.OTHER +} + +/** A display name for a source language code, covering the codes only Mihon extensions use. */ +fun getSourceLanguageDisplayName(context: Context, code: String): String = + code.toLocaleOrNull()?.getDisplayName(context) ?: getExternalExtensionLanguageDisplayName(code) 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..37e3d13702 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 @@ -25,11 +25,13 @@ import dagger.hilt.android.EntryPointAccessors import io.github.landwarderer.futon.BuildConfig import io.github.landwarderer.futon.R import io.github.landwarderer.futon.alternatives.ui.AlternativesActivity +import io.github.landwarderer.futon.alternatives.ui.SourceReplacementActivity import io.github.landwarderer.futon.backups.ui.backup.BackupDialogFragment 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 @@ -178,7 +180,20 @@ class AppRouter private constructor( fun openAlternatives(manga: Manga) { startActivity( Intent(contextOrNull() ?: return, AlternativesActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga)), + .putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false)), + ) + } + + fun openSourceReplacement(manga: Collection) { + if (manga.isEmpty()) { + return + } + startActivity( + Intent(contextOrNull() ?: return, SourceReplacementActivity::class.java) + .putParcelableArrayListExtra( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, + ), ) } @@ -727,14 +742,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/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/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/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..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,33 +13,21 @@ 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.coroutines.suspendCoroutine import kotlin.ranges.contains @Singleton 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 @@ -67,15 +55,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 +76,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,49 +152,35 @@ class WebViewExecutor @Inject constructor( }, timeoutMs) } } finally { - // If already resumed, stopLoading() was called; this is a safety call. - webView.stopLoading() + handler.removeCallbacksAndMessages(null) + destroyEvaluatedWebView(webView) } } } - 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) webViewCached = WeakReference(it) } - private fun MangaSource.getUserAgent(): String? { - val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository - return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) + /** + * 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() } } @MainThread @@ -202,13 +189,4 @@ class WebViewExecutor @Inject constructor( }.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/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/os/AppShortcutManager.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/os/AppShortcutManager.kt index 54b5e30f00..1f0858c2e8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/os/AppShortcutManager.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/os/AppShortcutManager.kt @@ -19,6 +19,7 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.LocalizedAppContext import io.github.landwarderer.futon.core.db.TABLE_HISTORY 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.nav.ReaderIntent import io.github.landwarderer.futon.core.parser.MangaDataRepository @@ -72,12 +73,19 @@ class AppShortcutManager @Inject constructor( } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == AppSettings.KEY_SHORTCUTS) { - if (settings.isDynamicShortcutsEnabled) { + when (key) { + AppSettings.KEY_SHORTCUTS -> if (settings.isDynamicShortcutsEnabled) { onInvalidated(emptySet()) } else { clearShortcuts() } + + // Rebuild straight away, so turning the filter on removes what is already on the home screen + // instead of waiting for the next history change. + AppSettings.KEY_SHORTCUTS_NO_NSFW, + AppSettings.KEY_DISABLE_NSFW -> if (settings.isDynamicShortcutsEnabled) { + onInvalidated(emptySet()) + } } } @@ -119,8 +127,13 @@ class AppShortcutManager @Inject constructor( private suspend fun updateShortcutsImpl() = runCatchingCancellable { val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5) - val shortcuts = historyRepository.getList(0, maxShortcuts) - .filter { x -> x.title.isNotEmpty() } + val skipNsfw = settings.isShortcutsNsfwDisabled || settings.isNsfwContentDisabled + // Over-fetch when filtering, so a run of adult titles at the top of the history does not leave + // the launcher with only one or two shortcuts. + val limit = if (skipNsfw) maxShortcuts * HISTORY_OVERFETCH else maxShortcuts + val shortcuts = historyRepository.getList(0, limit) + .filter { x -> x.title.isNotEmpty() && (!skipNsfw || !x.isNsfw()) } + .take(maxShortcuts) .map { buildShortcutInfo(it) } ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) }.onFailure { @@ -191,4 +204,10 @@ class AppShortcutManager @Inject constructor( .setIntent(AppRouter.listIntent(context, source, null, null)) .build() } + + private companion object { + + /** How much extra history to read when adult titles are being filtered out. */ + const val HISTORY_OVERFETCH = 4 + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaDataRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaDataRepository.kt index 3c94bc2d31..3592ac85d5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaDataRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/parser/MangaDataRepository.kt @@ -17,9 +17,11 @@ import io.github.landwarderer.futon.core.db.entity.MangaPrefsEntity import io.github.landwarderer.futon.core.db.entity.toEntities import io.github.landwarderer.futon.core.db.entity.toEntity import io.github.landwarderer.futon.core.db.entity.toManga +import io.github.landwarderer.futon.core.db.entity.toMangaChapter import io.github.landwarderer.futon.core.db.entity.toMangaChapters import io.github.landwarderer.futon.core.db.entity.toMangaTags import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.findSameChapter import io.github.landwarderer.futon.core.model.isLocal import io.github.landwarderer.futon.core.nav.MangaIntent import io.github.landwarderer.futon.core.os.AppShortcutManager @@ -27,8 +29,10 @@ import io.github.landwarderer.futon.core.prefs.ReaderMode import io.github.landwarderer.futon.core.ui.model.MangaOverride import io.github.landwarderer.futon.core.util.ext.toFileOrNull import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.nullIfEmpty import io.github.landwarderer.futon.reader.domain.ReaderColorFilter import javax.inject.Inject @@ -123,6 +127,15 @@ class MangaDataRepository @Inject constructor( return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() } + /** Titles of [source] that are in the user's favourites or reading history. */ + suspend fun findLibraryMangaBySource(source: MangaSource): List { + return db.getMangaDao().findLibraryBySource(source.name).map { it.toManga() } + } + + suspend fun findAllMangaBySource(source: MangaSource): List { + return db.getMangaDao().findAllBySource(source.name).map { it.toManga() } + } + suspend fun resolveIntent(intent: MangaIntent, withChapters: Boolean): Manga? = when { intent.manga != null -> intent.manga.withCachedChaptersIfNeeded(withChapters) intent.mangaId != 0L -> findMangaById(intent.mangaId, withChapters) @@ -147,6 +160,7 @@ class MangaDataRepository @Inject constructor( db.getMangaDao().upsert(manga.toEntity(), tags) if (!manga.isLocal) { manga.chapters?.let { chapters -> + keepHistoryChapter(manga.id, chapters) db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) } } @@ -157,8 +171,35 @@ class MangaDataRepository @Inject constructor( suspend fun updateChapters(manga: Manga) { val chapters = manga.chapters if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) { - db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) + db.withTransaction { + keepHistoryChapter(manga.id, chapters) + db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) + } + } + } + + /** + * The chapter as it was in the chapter list last stored for [mangaId], which may hold ids that a fresher load + * of the source no longer has. + */ + suspend fun findCachedChapter(mangaId: Long, chapterId: Long): MangaChapter? { + return db.getChaptersDao().find(mangaId, chapterId)?.toMangaChapter() + } + + /** + * History only keeps a chapter id. When the source gives the history chapter a new id, the stored list is the + * last place that still says which chapter it was, so move the history onto that chapter of [newChapters] + * before the list is replaced. Otherwise the reading position is later guessed from a percentage, possibly + * in another branch. + */ + private suspend fun keepHistoryChapter(mangaId: Long, newChapters: List) { + val history = db.getHistoryDao().find(mangaId) ?: return + if (newChapters.findById(history.chapterId) != null) { + return } + val previous = findCachedChapter(mangaId, history.chapterId) ?: return + val same = newChapters.findSameChapter(previous) ?: return + db.getHistoryDao().update(history.copy(chapterId = same.id)) } suspend fun gcChaptersCache() { 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..ddb4fc7fbc 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 @@ -198,9 +198,13 @@ class AppSettings internal constructor( get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false) set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) } + var isReaderDoubleCoverPage: Boolean + get() = prefs.getBoolean(KEY_READER_DOUBLE_COVER_PAGE, false) + set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_COVER_PAGE, value) } + @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 @@ -225,6 +229,18 @@ class AppSettings internal constructor( val isReaderOptimizationEnabled: Boolean get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) + val isEInkFlashEnabled: Boolean + get() = prefs.getBoolean(KEY_EINK_FLASH, false) + + val eInkFlashDuration: Int + get() = prefs.getInt(KEY_EINK_FLASH_DURATION, 300).coerceIn(100, 1000) + + val eInkFlashEvery: Int + get() = prefs.getInt(KEY_EINK_FLASH_EVERY, 1).coerceAtLeast(1) + + val eInkFlashColor: EInkFlashColor + get() = prefs.getEnumValue(KEY_EINK_FLASH_COLOR, EInkFlashColor.WHITE) + val readerControls: Set get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) { ReaderControl.entries.find(it) @@ -255,6 +271,17 @@ 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) } + + 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 @@ -348,6 +375,14 @@ class AppSettings internal constructor( val isDynamicShortcutsEnabled: Boolean get() = prefs.getBoolean(KEY_SHORTCUTS, true) + /** + * Keep adult manga out of the launcher's long-press shortcuts. + * + * Defaults to `true`: the home screen is visible to anyone holding the phone. + */ + val isShortcutsNsfwDisabled: Boolean + get() = prefs.getBoolean(KEY_SHORTCUTS_NO_NSFW, true) + val isUnstableUpdatesAllowed: Boolean get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) @@ -404,6 +439,11 @@ class AppSettings internal constructor( get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false) set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) } + /** Id of the active source preset, or 0 when the regular enabled sources are used. */ + var activeSourcePresetId: Long + get() = getLongCompat(KEY_ACTIVE_SOURCE_PRESET, 0L) + set(value) = prefs.edit { putLong(KEY_ACTIVE_SOURCE_PRESET, value) } + val isPagesNumbersEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) @@ -413,6 +453,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() @@ -506,8 +549,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 +637,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, @@ -786,7 +829,7 @@ class AppSettings internal constructor( if (!isMerge) { clear() } - putAll(m) + putAll(normalizeImportedPreferences(m)) } private fun isBackgroundNetworkRestricted(): Boolean { @@ -797,6 +840,54 @@ class AppSettings internal constructor( } } + // Long preferences restored from a JSON backup come back as Integer (or String) + private fun getLongCompat(key: String, defaultValue: Long): Long = try { + prefs.getLong(key, defaultValue) + } catch (_: ClassCastException) { + val fixedValue = when (val rawValue = prefs.all[key]) { + is Number -> rawValue.toLong() + is String -> rawValue.toLongOrNull() + else -> null + } + if (fixedValue == null) { + prefs.edit { remove(key) } + defaultValue + } else { + prefs.edit { putLong(key, fixedValue) } + fixedValue + } + } + + private fun normalizeImportedPreferences(values: Map): Map { + val fixedValue = when (val rawValue = values[KEY_ACTIVE_SOURCE_PRESET] ?: return values) { + is Long -> return values + is Number -> rawValue.toLong() + is String -> rawValue.toLongOrNull() ?: return values + else -> return values + } + return HashMap(values).apply { + put(KEY_ACTIVE_SOURCE_PRESET, fixedValue) + } + } + + // 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 @@ -806,6 +897,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" @@ -831,6 +923,7 @@ class AppSettings internal constructor( const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2" const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable" + const val KEY_READER_DOUBLE_COVER_PAGE = "reader_double_cover_page" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted" @@ -846,6 +939,9 @@ 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" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" @@ -909,8 +1005,13 @@ class AppSettings internal constructor( const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_SHORTCUTS = "dynamic_shortcuts" + const val KEY_SHORTCUTS_NO_NSFW = "shortcuts_no_nsfw" const val KEY_READER_TAP_ACTIONS = "reader_tap_actions" const val KEY_READER_OPTIMIZE = "reader_optimize" + const val KEY_EINK_FLASH = "eink_flash" + const val KEY_EINK_FLASH_DURATION = "eink_flash_duration" + const val KEY_EINK_FLASH_EVERY = "eink_flash_every" + const val KEY_EINK_FLASH_COLOR = "eink_flash_color" const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_HISTORY_ORDER = "history_order" const val KEY_FAVORITES_ORDER = "fav_order" @@ -969,6 +1070,7 @@ class AppSettings internal constructor( const val KEY_MODE_SWITCHER_SPOTLIGHT_SHOWN = "mode_switcher_spotlight_shown" const val KEY_ABOUT_ONBOARDING = "about_onboarding" const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all" + const val KEY_ACTIVE_SOURCE_PRESET = "active_source_preset" const val KEY_QUICK_FILTER = "quick_filter" const val KEY_COLLAPSE_DESCRIPTION = "description_collapse" const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/EInkFlashColor.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/EInkFlashColor.kt new file mode 100644 index 0000000000..040a050c2c --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/EInkFlashColor.kt @@ -0,0 +1,11 @@ +package io.github.landwarderer.futon.core.prefs + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.Keep + +@Keep +enum class EInkFlashColor(@ColorInt val colorInt: Int) { + WHITE(Color.WHITE), + BLACK(Color.BLACK), +} 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..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,38 @@ 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. + * + * 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) { @@ -78,7 +113,11 @@ 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" + const val KEY_CF_USER_AGENT_BASE = "cf_user_agent_base" + private const val KEY_USER_AGENT = "user_agent" // ConfigKey.UserAgent } } 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/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/core/util/MimeTypes.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/MimeTypes.kt index e3060dd1c7..74b179d60a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/MimeTypes.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/MimeTypes.kt @@ -14,16 +14,31 @@ import coil3.util.MimeTypeMap as CoilMimeTypeMap object MimeTypes { + // Neither Coil's table nor the platform's knows JPEG XL. Without this, .jxl pages are not + // recognised as images in local archives, and downloaded ones are saved without an extension. + private const val EXTENSION_JXL = "jxl" + val JXL = MimeType("image/jxl") + fun getMimeTypeFromExtension(fileName: String): MimeType? { - return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null) - ?.toMimeTypeOrNull() + val extension = getNormalizedExtension(fileName) ?: return null + if (extension == EXTENSION_JXL) { + return JXL + } + return CoilMimeTypeMap.getMimeTypeFromExtension(extension)?.toMimeTypeOrNull() } fun getMimeTypeFromUrl(url: String): MimeType? { + val fileName = url.substringBefore('#').substringBefore('?').substringAfterLast('/') + if (getNormalizedExtension(fileName) == EXTENSION_JXL) { + return JXL + } return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull() } fun getExtension(mimeType: MimeType?): String? { + if (mimeType == JXL) { + return EXTENSION_JXL + } return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty() } 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()) } 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..89ab64e0a2 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,31 @@ fun Throwable.getDisplayIcon(): Int = when (this) { else -> R.drawable.ic_error_large } +/** + * Whether this error (or any of its causes) means the manga itself is missing from its source. + * Deliberately narrower than [getDisplayMessage]'s 404 mapping, so callers can offer to look for + * the title on another source. + */ +fun Throwable.isContentNotFound(): Boolean = generateSequence(this) { it.cause?.takeIf { c -> c !== it } } + .take(32) + .any { e -> + when (e) { + is NotFoundException, + is ContentUnavailableException -> true + + is HttpException -> e.response.code == HttpURLConnection.HTTP_NOT_FOUND + is HttpStatusException -> e.statusCode == HttpURLConnection.HTTP_NOT_FOUND + is eu.kanade.tachiyomi.network.HttpException -> e.code == HttpURLConnection.HTTP_NOT_FOUND + else -> false + } + } + +/** 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/details/domain/ProgressUpdateUseCase.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/ProgressUpdateUseCase.kt index c4b1893322..b7dfb800a0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/ProgressUpdateUseCase.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/domain/ProgressUpdateUseCase.kt @@ -2,9 +2,12 @@ package io.github.landwarderer.futon.details.domain import androidx.room.withTransaction import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.model.getPreferredBranch import io.github.landwarderer.futon.core.model.isLocal import io.github.landwarderer.futon.core.os.NetworkState import io.github.landwarderer.futon.core.parser.MangaRepository +import io.github.landwarderer.futon.history.data.HistoryEntity +import io.github.landwarderer.futon.list.domain.ReadingProgress import io.github.landwarderer.futon.list.domain.ReadingProgress.Companion.PROGRESS_NONE import io.github.landwarderer.futon.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga @@ -33,7 +36,8 @@ class ProgressUpdateUseCase @Inject constructor( } else { seed } - val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE + val chapter = details.findChapterById(history.chapterId) + ?: return estimateFromCounts(manga, details, history) val chapters = details.getChapters(chapter.branch) val chapterRepo = if (repo.source == chapter.source) { repo @@ -52,19 +56,62 @@ class ProgressUpdateUseCase @Inject constructor( val pagePercent = (history.page + 1) / pagesCount.toFloat() val ppc = 1f / chaptersCount val result = ppc * chapterIndex + ppc * pagePercent + // history.chaptersCount is the chapter count at the reader's last save: smart resume relies on it + // to tell that the saved chapter was the latest one, so only the reader may change it. if (result != history.percent) { - database.withTransaction { - // Loading metadata may outlive a newer reader save. Never restore that stale snapshot. - if (database.getHistoryDao().find(manga.id) == history) { - database.getHistoryDao().update( - history.copy( - chapterId = chapter.id, - percent = result, - ), - ) - } - } + updateIfUnchanged( + manga = manga, + history = history, + newHistory = history.copy( + chapterId = chapter.id, + percent = result, + ), + ) } return result } + + /** + * Fallback when the stored history chapter id is no longer present in the fresh details + * (e.g. the source rotated chapter URLs, which changes derived ids). We can't pinpoint the + * reading position anymore, so we estimate it from the previously stored counts: roughly + * `percent * oldTotal` chapters were read, rescaled to the new total. The read position + * itself is not touched. + * + * The rescale may only ever move progress *down*: the stored count and the new total are not + * guaranteed to describe the same branch, and with only a few chapters in the new total almost + * any stored percent would clamp to 1.0 and mark the manga completed. A guess must never claim + * a completion the user never reached. + */ + private suspend fun estimateFromCounts(manga: Manga, details: Manga, history: HistoryEntity): Float { + val newTotal = details.getChapters(details.getPreferredBranch(null)).size + .takeIf { it > 0 } ?: details.chapters?.size ?: 0 + if (newTotal == 0 || history.chaptersCount <= 0 || !ReadingProgress.isValid(history.percent)) { + return PROGRESS_NONE + } + val estimated = if (ReadingProgress.isCompleted(history.percent)) { + // Genuinely finished before the ids rotated: stay finished. + history.percent + } else { + // Gaining chapters legitimately lowers progress; losing them may not raise it. + (history.percent * history.chaptersCount / newTotal).coerceIn(0f, history.percent) + } + if (estimated != history.percent || history.chaptersCount != newTotal) { + updateIfUnchanged( + manga = manga, + history = history, + newHistory = history.copy(percent = estimated, chaptersCount = newTotal), + ) + } + return estimated + } + + private suspend fun updateIfUnchanged(manga: Manga, history: HistoryEntity, newHistory: HistoryEntity) { + database.withTransaction { + // Loading metadata may outlive a newer reader save. Never restore that stale snapshot. + if (database.getHistoryDao().find(manga.id) == history) { + database.getHistoryDao().update(newHistory) + } + } + } } 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/DetailsErrorObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsErrorObserver.kt index 6da34dacfa..925378328e 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 @@ -1,20 +1,30 @@ package io.github.landwarderer.futon.details.ui +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.google.android.material.snackbar.Snackbar import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.ui.MangaUnavailableDialog +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.model.isLocal +import io.github.landwarderer.futon.core.nav.router +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.isContentNotFound import io.github.landwarderer.futon.core.util.ext.isNetworkError import io.github.landwarderer.futon.core.util.ext.isSerializable import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.ParseException +import org.koitharu.kotatsu.parsers.model.Manga 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 +34,44 @@ class DetailsErrorObserver( }, ) { + private var unavailableDialog: MangaUnavailableDialog? = null + + init { + // The prompt is an activity-hosted dialog, so it has to go down with the activity. + activity.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + unavailableDialog?.dismiss() + unavailableDialog = null + } + }, + ) + } + + private var isAutoResolveAttempted = false + override suspend fun emit(value: Throwable) { + // A title that is gone from its source is a dead end, so ask up front whether + // to go looking for it on another source. + val missingManga = viewModel.getMangaOrNull()?.takeIf { !it.isLocal && value.isContentNotFound() } + if (missingManga != null && showUnavailableDialog(missingManga)) { + return + } + // 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) { @@ -54,4 +101,23 @@ class DetailsErrorObserver( } snackbar.show() } + + /** + * @return `true` if the prompt is on screen and the caller should not show anything else. + * A dialog that is already up is left alone: reloads re-emit the same error. + */ + private fun showUnavailableDialog(manga: Manga): Boolean { + if (unavailableDialog?.isShowing == true) { + return true + } + if (activity.isDestroyed || activity.isFinishing) { + return false + } + val router = activity.router + unavailableDialog = MangaUnavailableDialog.Builder(activity, manga) + .setOnAlternativesClickListener { router.openAlternatives(manga) } + .create() + .also { it.show() } + return true + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt index bf5ecf08cf..91562a25aa 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/DetailsMenuProvider.kt @@ -49,6 +49,7 @@ class DetailsMenuProvider( menu.findItem(R.id.action_delete).isVisible = mangaDetails?.local != null menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource + menu.findItem(R.id.action_replace_source).isVisible = manga?.source != LocalMangaSource menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null @@ -91,6 +92,10 @@ class DetailsMenuProvider( router.openAlternatives(manga) } + R.id.action_replace_source -> { + router.openSourceReplacement(listOf(manga)) + } + R.id.action_stats -> { router.showStatisticSheet(manga) } 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..03dc2af224 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 @@ -13,7 +14,6 @@ import io.github.landwarderer.futon.core.prefs.TriStateOption import io.github.landwarderer.futon.core.ui.util.ReversibleAction import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.core.util.ext.computeSize -import io.github.landwarderer.futon.core.util.ext.onEachWhile import io.github.landwarderer.futon.details.data.MangaDetails import io.github.landwarderer.futon.details.domain.BranchComparator import io.github.landwarderer.futon.details.domain.DetailsInteractor @@ -154,8 +154,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 { @@ -239,22 +246,34 @@ class DetailsViewModel @Inject constructor( } private fun doLoad(force: Boolean) = launchLoadingJob(Dispatchers.IO) { + var defaultBranch: DefaultBranch? = null detailsLoadUseCase.invoke(intent, force) - .onEachWhile { - if (it.allChapters.isNotEmpty()) { + .onEach { + if (it.allChapters.isEmpty()) { + return@onEach + } + // The first list is usually the stored one, then comes the one just loaded from the source. Pick the + // default branch again when the one picked from the stored list is not in the new one (the source + // renamed its branches), or when it was not picked from the history and the user has kept it. + val picked = defaultBranch + val current = selectedBranch.value + if (picked == null || current !in it.chapters || (current == picked.name && !picked.isFromHistory)) { val manga = it.toManga() - // find default branch val hist = historyRepository.getOne(manga) - selectedBranch.value = manga.getPreferredBranch(hist) - true - } else { - false + val branch = manga.getPreferredBranch(hist) + selectedBranch.value = branch + defaultBranch = DefaultBranch( + name = branch, + isFromHistory = hist != null && manga.findChapterById(hist.chapterId) != null, + ) } }.collect { mangaDetails.value = it } } + private class DefaultBranch(val name: String?, val isFromHistory: Boolean) + private fun getScrobbler(index: Int): Scrobbler? { val info = scrobblingInfo.value.getOrNull(index) val scrobbler = if (info != null) { 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 5ce22c9f47..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 @@ -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 @@ -54,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() @@ -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/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/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) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt index 0d8bde41c5..698d73b2b8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/MangaSourcesRepository.kt @@ -17,6 +17,7 @@ import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.updateMihonTitle import io.github.landwarderer.futon.core.model.isBroken import io.github.landwarderer.futon.core.model.isNsfw +import io.github.landwarderer.futon.core.model.languageCode import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.observeAsFlow @@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -254,6 +256,72 @@ class MangaSourcesRepository @Inject constructor( list } + /** + * The sources a preset refers to that can still be used: built-in parsers, installed Mihon extensions + * and external plugins, minus broken and (when hidden) NSFW ones, in the same order as the sources list. + * Names of sources that have since been removed or uninstalled are skipped, not reported. + */ + suspend fun getPresetSources(preset: SourcePreset): List { + if (preset.sources.isEmpty()) { + return emptyList() + } + assimilateNewSources() + val order = settings.sourcesSortOrder + val entities = dao.findAll(enabledOnly = false, order = order) + return mergePresetSources( + preset = preset, + fromDb = entities.filter { it.source in preset.sources }.toSources(settings.isNsfwContentDisabled, order), + external = getExternalSources(), + ) + } + + fun observePresetSources(preset: SourcePreset): Flow> = if (preset.sources.isEmpty()) { + flowOf(emptyList()) + } else { + combine( + observeIsNsfwDisabled(), + observeSortOrder(), + ) { skipNsfw, order -> + dao.observeAll(enabledOnly = false, order = order).map { entities -> + entities.filter { it.source in preset.sources }.toSources(skipNsfw, order) + } + }.flattenLatest() + .onStart { assimilateNewSources() } + .combine(observeExternalSources()) { fromDb, external -> + mergePresetSources(preset, fromDb, external) + } + } + + /** Every language code a source can declare, built-in parsers and installed Mihon extensions alike. */ + fun getAllSourceLanguages(): Set = buildSet { + allMangaSources.mapNotNullTo(this) { it.languageCode } + getMihonSources().mapNotNullTo(this) { it.languageCode } + } + + /** Names of the usable sources in [languages], as the starting point of a new preset. */ + fun getSourceNamesForLanguages(languages: Set): Set { + if (languages.isEmpty()) { + return emptySet() + } + val skipNsfw = settings.isNsfwContentDisabled + return (allMangaSources.asSequence() + getMihonSources().asSequence()) + .filter { it.languageCode in languages && (!skipNsfw || !it.isNsfw()) } + .mapTo(LinkedHashSet()) { it.name } + } + + private fun mergePresetSources( + preset: SourcePreset, + fromDb: List, + external: List, + ): List { + val dbNames = fromDb.mapToSet { it.name } + val result = ArrayList(fromDb.size) + external.filter { it.name in preset.sources && it.name !in dbNames } + .mapTo(result) { MangaSourceInfo(it, isEnabled = true, isPinned = false) } + fromDb.mapTo(result) { it.copy(isEnabled = true) } + return result + } + fun observeAll(): Flow>> = dao.observeAll().map { entities -> val result = ArrayList>(entities.size) for (entity in entities) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePreset.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePreset.kt new file mode 100644 index 0000000000..1e03881673 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePreset.kt @@ -0,0 +1,43 @@ +package io.github.landwarderer.futon.explore.data + +import android.os.Parcelable +import io.github.landwarderer.futon.list.ui.model.ListModel +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SourcePreset( + val id: Long, + val title: String, + val languages: Set, + val sources: Set, + val createdAt: Long, + val sortKey: Int, +) : Parcelable, ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourcePreset && id == other.id + } +} + +fun SourcePresetEntity.toSourcePreset() = SourcePreset( + id = presetId, + title = title, + languages = languages.splitPresetValues(), + sources = sources.splitPresetValues(), + createdAt = createdAt, + sortKey = sortKey, +) + +internal fun Collection.joinPresetValues(): String = asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .joinToString(",") + +internal fun String.splitPresetValues(): Set = if (isBlank()) { + emptySet() +} else { + splitToSequence(',') + .map { it.trim() } + .filterTo(LinkedHashSet()) { it.isNotEmpty() } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetEntity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetEntity.kt new file mode 100644 index 0000000000..846c90806b --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetEntity.kt @@ -0,0 +1,18 @@ +package io.github.landwarderer.futon.explore.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.github.landwarderer.futon.core.db.TABLE_SOURCE_PRESETS + +@Entity(tableName = TABLE_SOURCE_PRESETS) +data class SourcePresetEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "preset_id") val presetId: Long, + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "languages") val languages: String, + @ColumnInfo(name = "sources") val sources: String, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "sort_key") val sortKey: Int, + @ColumnInfo(name = "deleted_at", defaultValue = "0") val deletedAt: Long, +) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsDao.kt new file mode 100644 index 0000000000..4e55a8cfe6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsDao.kt @@ -0,0 +1,50 @@ +package io.github.landwarderer.futon.explore.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class SourcePresetsDao { + + @Query("SELECT * FROM source_presets WHERE deleted_at = 0 ORDER BY sort_key") + abstract suspend fun findAll(): List + + @Query("SELECT * FROM source_presets WHERE deleted_at = 0 ORDER BY sort_key") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM source_presets WHERE preset_id = :id AND deleted_at = 0") + abstract suspend fun find(id: Long): SourcePresetEntity? + + @Query("SELECT * FROM source_presets WHERE preset_id = :id AND deleted_at = 0") + abstract fun observe(id: Long): Flow + + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun insert(entity: SourcePresetEntity): Long + + @Query("UPDATE source_presets SET title = :title, languages = :languages WHERE preset_id = :id") + abstract suspend fun update(id: Long, title: String, languages: String) + + @Query("UPDATE source_presets SET sources = :sources WHERE preset_id = :id") + abstract suspend fun updateSources(id: Long, sources: String) + + @Query("UPDATE source_presets SET sort_key = :sortKey WHERE preset_id = :id") + abstract suspend fun updateSortKey(id: Long, sortKey: Int) + + suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis()) + + @Query("DELETE FROM source_presets WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") + abstract suspend fun gc(maxDeletionTime: Long) + + @Query("SELECT MAX(sort_key) FROM source_presets WHERE deleted_at = 0") + protected abstract suspend fun getMaxSortKey(): Int? + + suspend fun getNextSortKey(): Int { + return (getMaxSortKey() ?: 0) + 1 + } + + @Query("UPDATE source_presets SET deleted_at = :deletedAt WHERE preset_id = :id") + protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsRepository.kt new file mode 100644 index 0000000000..0035d8e74e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/data/SourcePresetsRepository.kt @@ -0,0 +1,67 @@ +package io.github.landwarderer.futon.explore.data + +import dagger.Reusable +import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.util.ext.mapItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@Reusable +class SourcePresetsRepository @Inject constructor( + private val db: MangaDatabase, +) { + + private val dao: SourcePresetsDao + get() = db.getSourcePresetsDao() + + fun observeAll(): Flow> { + return dao.observeAll().mapItems { it.toSourcePreset() } + } + + fun observe(id: Long): Flow { + return dao.observe(id).map { it?.toSourcePreset() } + } + + suspend fun getAll(): List { + return dao.findAll().map { it.toSourcePreset() } + } + + suspend fun getById(id: Long): SourcePreset? { + if (id == NO_PRESET) { + return null + } + return dao.find(id)?.toSourcePreset() + } + + suspend fun createPreset(title: String, languages: Set, sources: Set): SourcePreset { + val entity = SourcePresetEntity( + presetId = 0, + title = title, + languages = languages.joinPresetValues(), + sources = sources.joinPresetValues(), + createdAt = System.currentTimeMillis(), + sortKey = dao.getNextSortKey(), + deletedAt = 0L, + ) + val id = dao.insert(entity) + return entity.copy(presetId = id).toSourcePreset() + } + + suspend fun updatePreset(id: Long, title: String, languages: Set) { + dao.update(id, title, languages.joinPresetValues()) + } + + suspend fun updatePresetSources(id: Long, sources: Set) { + dao.updateSources(id, sources.joinPresetValues()) + } + + suspend fun deletePreset(id: Long) { + dao.delete(id) + } + + companion object { + + const val NO_PRESET = 0L + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt index ce9eed0b7f..fc8a116e62 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt @@ -11,6 +11,7 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.PopupMenu import androidx.appcompat.view.ActionMode import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels @@ -42,6 +43,7 @@ import io.github.landwarderer.futon.databinding.FragmentExploreBinding import io.github.landwarderer.futon.explore.ui.adapter.ExploreAdapter import io.github.landwarderer.futon.explore.ui.adapter.ExploreListEventListener import io.github.landwarderer.futon.explore.ui.model.MangaSourceItem +import io.github.landwarderer.futon.explore.ui.preset.SourcePresetListActivity import io.github.landwarderer.futon.list.ui.adapter.TypedListSpacingDecoration import io.github.landwarderer.futon.list.ui.model.ListHeader import org.koitharu.kotatsu.parsers.model.Manga @@ -83,7 +85,7 @@ class ExploreFragment : addItemDecoration(TypedListSpacingDecoration(context, false)) checkNotNull(sourceSelectionController).attachToRecyclerView(this) } - addMenuProvider(ExploreMenuProvider(router)) + addMenuProvider(ExploreMenuProvider(binding.root.context, router)) viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) @@ -115,6 +117,9 @@ class ExploreFragment : override fun onListHeaderClick(item: ListHeader, view: View) { if (item.payload == R.id.nav_suggestions) { router.openSuggestions() + } else if (viewModel.isPresetActive) { + // A preset's sources are picked in the catalog, which switches to preset mode. + router.openSourcesCatalog() } else if (viewModel.isAllSourcesEnabled.value) { router.openManageSources() } else { @@ -129,7 +134,28 @@ class ExploreFragment : R.id.button_more -> router.openSuggestions() R.id.button_downloads -> router.openDownloads() R.id.button_random -> viewModel.openRandom() + R.id.button_presets -> startActivity(SourcePresetListActivity.newIntent(v.context)) + R.id.button_presets_dropdown -> showPresetsPopup(v) + } + } + + private fun showPresetsPopup(anchor: View) { + val presets = viewModel.presets.value + val activeId = viewModel.activePresetId + val popup = PopupMenu(anchor.context, anchor) + popup.menu.add(Menu.NONE, 0, 0, R.string.default_sources).isChecked = presets.none { it.id == activeId } + presets.forEachIndexed { index, preset -> + popup.menu.add(Menu.NONE, index + 1, index + 1, preset.title).isChecked = preset.id == activeId + } + popup.menu.setGroupCheckable(Menu.NONE, true, true) + popup.setOnMenuItemClickListener { menuItem -> + val preset = presets.getOrNull(menuItem.itemId - 1) + viewModel.setActivePreset( + if (preset == null || preset.id == activeId) 0L else preset.id, + ) + true } + popup.show() } override fun onItemClick(item: MangaSourceItem, view: View) { @@ -171,8 +197,8 @@ class ExploreFragment : menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned } menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned } - menu.findItem(R.id.action_disable)?.isVisible = !viewModel.isAllSourcesEnabled.value && - selectedSources.all { it.mangaSource is MangaParserSource } + menu.findItem(R.id.action_disable)?.isVisible = viewModel.isPresetActive || + (!viewModel.isAllSourcesEnabled.value && selectedSources.all { it.mangaSource is MangaParserSource }) menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource } return super.onPrepareActionMode(controller, mode, menu) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreMenuProvider.kt index 6ab504fdf5..da71722778 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreMenuProvider.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreMenuProvider.kt @@ -1,13 +1,16 @@ package io.github.landwarderer.futon.explore.ui +import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.explore.ui.preset.SourcePresetListActivity class ExploreMenuProvider( + private val context: Context, private val router: AppRouter, ) : MenuProvider { @@ -22,6 +25,11 @@ class ExploreMenuProvider( true } + R.id.action_presets -> { + context.startActivity(SourcePresetListActivity.newIntent(context)) + true + } + else -> false } } 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..e711345edb 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 @@ -16,6 +16,8 @@ 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.combine import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository import io.github.landwarderer.futon.explore.domain.ExploreRepository import io.github.landwarderer.futon.explore.ui.model.ExploreButtons import io.github.landwarderer.futon.explore.ui.model.MangaSourceItem @@ -48,6 +50,7 @@ class ExploreViewModel @Inject constructor( private val suggestionRepository: SuggestionRepository, private val exploreRepository: ExploreRepository, private val sourcesRepository: MangaSourcesRepository, + private val presetsRepository: SourcePresetsRepository, private val shortcutManager: AppShortcutManager, ) : BaseViewModel() { @@ -74,6 +77,23 @@ class ExploreViewModel @Inject constructor( valueProducer = { isSuggestionsEnabled }, ) + /** The active preset, or null when there is none or it no longer exists (deleted, not restored...). */ + private val activePresetFlow: StateFlow = settings.observeAsFlow( + key = AppSettings.KEY_ACTIVE_SOURCE_PRESET, + valueProducer = { activeSourcePresetId }, + ).flatMapLatest { id -> + if (id == SourcePresetsRepository.NO_PRESET) flowOf(null) else presetsRepository.observe(id) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, null) + + val presets: StateFlow> = presetsRepository.observeAll() + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, emptyList()) + + val activePresetId: Long + get() = activePresetFlow.value?.id ?: SourcePresetsRepository.NO_PRESET + + val isPresetActive: Boolean + get() = activePresetFlow.value != null + val onOpenManga = MutableEventFlow() val onActionDone = MutableEventFlow() val onShowSuggestionsTip = MutableEventFlow() @@ -110,11 +130,23 @@ class ExploreViewModel @Inject constructor( } } + fun setActivePreset(presetId: Long) { + settings.activeSourcePresetId = presetId + } + fun disableSources(sources: Collection) { launchJob(Dispatchers.IO) { - val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false) val message = if (sources.size == 1) R.string.source_disabled else R.string.sources_disabled - onActionDone.call(ReversibleAction(message, rollback)) + val preset = activePresetFlow.value + if (preset != null) { + // With a preset active, "disable" takes the sources out of the preset, not out of the app. + val removed = sources.mapTo(HashSet(sources.size)) { it.name } + presetsRepository.updatePresetSources(preset.id, preset.sources - removed) + onActionDone.call(ReversibleAction(message, null)) + } else { + val rollback = sourcesRepository.setSourcesEnabled(sources, isEnabled = false) + onActionDone.call(ReversibleAction(message, rollback)) + } } } @@ -147,8 +179,17 @@ class ExploreViewModel @Inject constructor( } } + private fun observeSourcesForDisplay(): Flow> = activePresetFlow.flatMapLatest { preset -> + if (preset != null) { + sourcesRepository.observePresetSources(preset) + } else { + sourcesRepository.observeEnabledSources() + } + } + + /** The active preset (or the enabled sources) narrowed to the current manga / novel mode */ private fun observeFilteredSources(): Flow> = combineFlows( - sourcesRepository.observeEnabledSources(), + observeSourcesForDisplay(), appMode, ) { sources, mode -> val isNovel = mode.isNovel @@ -162,8 +203,9 @@ class ExploreViewModel @Inject constructor( isRandomLoading, isAllSourcesEnabled, sourcesRepository.observeHasNewSourcesForBadge(), - ) { sources, suggestions, grid, randomLoading, allSourcesEnabled, newSources -> - buildList(sources, suggestions, grid, randomLoading, allSourcesEnabled, newSources) + activePresetFlow, + ) { content, suggestions, grid, randomLoading, allSourcesEnabled, newSources, activePreset -> + buildList(content, suggestions, grid, randomLoading, allSourcesEnabled, newSources, activePreset) }.withErrorHandling() private fun buildList( @@ -173,9 +215,10 @@ class ExploreViewModel @Inject constructor( randomLoading: Boolean, allSourcesEnabled: Boolean, hasNewSources: Boolean, + activePreset: SourcePreset?, ): List { val result = ArrayList(sources.size + 3) - result += ExploreButtons(randomLoading) + result += ExploreButtons(randomLoading, activePreset?.title) if (recommendation.isNotEmpty()) { result += ListHeader(R.string.suggestions, R.string.more, R.id.nav_suggestions) result += RecommendationsItem(recommendation.toRecommendationList()) @@ -183,8 +226,8 @@ class ExploreViewModel @Inject constructor( if (sources.isNotEmpty()) { result += ListHeader( textRes = R.string.remote_sources, - buttonTextRes = if (allSourcesEnabled) R.string.manage else R.string.catalog, - badge = if (!allSourcesEnabled && hasNewSources) "" else null, + buttonTextRes = if (allSourcesEnabled && activePreset == null) R.string.manage else R.string.catalog, + badge = if (!allSourcesEnabled && activePreset == null && hasNewSources) "" else null, ) sources.mapTo(result) { MangaSourceItem(it, isGrid) } } else { @@ -199,8 +242,8 @@ class ExploreViewModel @Inject constructor( } private fun getLoadingStateList() = listOf( - ExploreButtons(isRandomLoading.value), - LoadingState, + ExploreButtons(isRandomLoading.value, activePresetFlow.value?.title), + LoadingState(), ) private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt index 23aca97250..f6c2fffeab 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -39,6 +39,8 @@ fun exploreButtonsAD( binding.buttonDownloads.setOnClickListener(clickListener) binding.buttonLocal.setOnClickListener(clickListener) binding.buttonRandom.setOnClickListener(clickListener) + binding.buttonPresets.setOnClickListener(clickListener) + binding.buttonPresetsDropdown.setOnClickListener(clickListener) bind { if (item.isRandomLoading) { @@ -47,6 +49,12 @@ fun exploreButtonsAD( binding.buttonRandom.setIconResource(R.drawable.ic_dice) } binding.buttonRandom.isClickable = !item.isRandomLoading + val presetName = item.activePresetName + if (presetName != null) { + binding.buttonPresets.text = presetName + } else { + binding.buttonPresets.setText(R.string.source_presets) + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/ExploreButtons.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/ExploreButtons.kt index f5b5ee6a7e..7057d28253 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/ExploreButtons.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/ExploreButtons.kt @@ -4,6 +4,7 @@ import io.github.landwarderer.futon.list.ui.model.ListModel data class ExploreButtons( val isRandomLoading: Boolean, + val activePresetName: String? = null, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetAdapter.kt new file mode 100644 index 0000000000..d5572e38ff --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetAdapter.kt @@ -0,0 +1,84 @@ +package io.github.landwarderer.futon.explore.ui.preset + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.getSourceLanguageDisplayName +import io.github.landwarderer.futon.databinding.ItemPresetBinding +import io.github.landwarderer.futon.explore.data.SourcePreset + +class SourcePresetAdapter( + private val listener: SourcePresetListener, +) : ListAdapter(PresetDiffCallback) { + + var activePresetId: Long = 0L + @SuppressLint("NotifyDataSetChanged") + set(value) { + if (field != value) { + field = value + notifyDataSetChanged() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PresetViewHolder { + val binding = ItemPresetBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PresetViewHolder(binding) + } + + override fun onBindViewHolder(holder: PresetViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class PresetViewHolder( + private val binding: ItemPresetBinding, + ) : RecyclerView.ViewHolder(binding.root), View.OnClickListener { + + private var boundItem: SourcePreset? = null + + init { + binding.root.setOnClickListener(this) + binding.buttonEdit.setOnClickListener(this) + binding.buttonDelete.setOnClickListener(this) + } + + fun bind(item: SourcePreset) { + boundItem = item + binding.textViewTitle.text = item.title + val context = binding.root.context + val langNames = item.languages.map { getSourceLanguageDisplayName(context, it) } + val count = item.sources.size + val countText = context.resources.getQuantityString(R.plurals.sources_count, count, count) + binding.textViewSubtitle.text = if (langNames.isNotEmpty()) { + "${langNames.joinToString()} — $countText" + } else { + countText + } + binding.radio.isChecked = item.id == activePresetId + } + + override fun onClick(v: View) { + val item = boundItem ?: return + when (v) { + binding.buttonEdit -> listener.onEditPreset(item) + binding.buttonDelete -> listener.onDeletePreset(item) + else -> listener.onPresetClick(item) + } + } + } + + private object PresetDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SourcePreset, newItem: SourcePreset) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: SourcePreset, newItem: SourcePreset) = oldItem == newItem + } +} + +interface SourcePresetListener { + fun onPresetClick(preset: SourcePreset) + fun onEditPreset(preset: SourcePreset) + fun onDeletePreset(preset: SourcePreset) +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditActivity.kt new file mode 100644 index 0000000000..249cdac00e --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditActivity.kt @@ -0,0 +1,137 @@ +package io.github.landwarderer.futon.explore.ui.preset + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.view.View +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import com.google.android.material.chip.Chip +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.model.getSourceLanguageDisplayName +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.ui.util.DefaultTextWatcher +import io.github.landwarderer.futon.core.util.ext.consumeAllSystemBarsInsets +import io.github.landwarderer.futon.core.util.ext.getDisplayMessage +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.systemBarsInsets +import io.github.landwarderer.futon.databinding.ActivityPresetEditBinding +import io.github.landwarderer.futon.explore.data.SourcePreset + +@AndroidEntryPoint +class SourcePresetEditActivity : + BaseActivity(), + View.OnClickListener, + DefaultTextWatcher { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityPresetEditBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + viewBinding.buttonDone.setOnClickListener(this) + viewBinding.editName.addTextChangedListener(this) + afterTextChanged(viewBinding.editName.text) + + initLanguageChips() + + viewModel.onSaved.observeEvent(this) { finishAfterTransition() } + viewModel.preset.observe(this, ::onPresetChanged) + viewModel.isLoading.observe(this, ::onLoadingStateChanged) + viewModel.onError.observeEvent(this, ::onError) + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.root.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.save( + title = viewBinding.editName.text?.toString()?.trim().orEmpty(), + selectedLanguages = getSelectedLanguages(), + ) + } + } + + override fun afterTextChanged(s: Editable?) { + viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() && !viewModel.isLoading.value + } + + private fun initLanguageChips() { + val chipGroup = viewBinding.chipGroupLanguages + chipGroup.removeAllViews() + val locales = viewModel.allLocales + .map { it to getSourceLanguageDisplayName(this, it) } + .sortedBy { it.second } + for ((locale, title) in locales) { + val chip = Chip(this).apply { + text = title + isCheckable = true + tag = locale + } + chipGroup.addView(chip) + } + } + + private fun onPresetChanged(preset: SourcePreset?) { + setTitle(if (preset == null) R.string.create_preset else R.string.edit_preset) + if (preset == null) return + if (viewBinding.editName.text.isNullOrEmpty()) { + viewBinding.editName.setText(preset.title) + } + val chipGroup = viewBinding.chipGroupLanguages + for (i in 0 until chipGroup.childCount) { + val chip = chipGroup.getChildAt(i) as? Chip ?: continue + chip.isChecked = chip.tag as? String in preset.languages + } + } + + private fun getSelectedLanguages(): Set { + val chipGroup = viewBinding.chipGroupLanguages + val result = LinkedHashSet() + for (i in 0 until chipGroup.childCount) { + val chip = chipGroup.getChildAt(i) as? Chip ?: continue + if (chip.isChecked) { + (chip.tag as? String)?.let { result.add(it) } + } + } + // Keep languages whose sources are not installed right now, so editing a preset does not drop them. + viewModel.preset.value?.languages?.filterTo(result) { it !in viewModel.allLocales } + return result + } + + private fun onError(e: Throwable) { + viewBinding.textViewError.text = e.getDisplayMessage(resources) + viewBinding.textViewError.isVisible = true + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + viewBinding.buttonDone.isEnabled = !isLoading && !viewBinding.editName.text.isNullOrBlank() + viewBinding.editName.isEnabled = !isLoading + if (isLoading) { + viewBinding.textViewError.isVisible = false + } + } + + companion object { + + fun newIntent(context: Context, presetId: Long = SourcePresetEditViewModel.NO_ID): Intent { + return Intent(context, SourcePresetEditActivity::class.java) + .putExtra(AppRouter.KEY_ID, presetId) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditViewModel.kt new file mode 100644 index 0000000000..e86fc18af3 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetEditViewModel.kt @@ -0,0 +1,56 @@ +package io.github.landwarderer.futon.explore.ui.preset + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.core.util.ext.MutableEventFlow +import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +@HiltViewModel +class SourcePresetEditViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val presetsRepository: SourcePresetsRepository, + private val sourcesRepository: MangaSourcesRepository, +) : BaseViewModel() { + + private val presetId = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID + + val onSaved = MutableEventFlow() + val preset = MutableStateFlow(null) + + val allLocales: Set = sourcesRepository.getAllSourceLanguages() + + init { + launchLoadingJob(Dispatchers.IO) { + preset.value = if (presetId != NO_ID) { + presetsRepository.getById(presetId) + } else { + null + } + } + } + + fun save(title: String, selectedLanguages: Set) { + launchLoadingJob(Dispatchers.IO) { + check(title.isNotEmpty()) + if (presetId == NO_ID) { + val initialSources = sourcesRepository.getSourceNamesForLanguages(selectedLanguages) + presetsRepository.createPreset(title, selectedLanguages, initialSources) + } else { + presetsRepository.updatePreset(presetId, title, selectedLanguages) + } + onSaved.call(Unit) + } + } + + companion object { + const val NO_ID = -1L + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListActivity.kt new file mode 100644 index 0000000000..6c6ed0baea --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListActivity.kt @@ -0,0 +1,116 @@ +package io.github.landwarderer.futon.explore.ui.preset + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.viewModels +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver +import io.github.landwarderer.futon.core.ui.BaseActivity +import io.github.landwarderer.futon.core.util.ext.consumeAllSystemBarsInsets +import io.github.landwarderer.futon.core.util.ext.end +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.systemBarsInsets +import io.github.landwarderer.futon.databinding.ActivityPresetListBinding +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository +import io.github.landwarderer.futon.list.ui.adapter.TypedListSpacingDecoration + +@AndroidEntryPoint +class SourcePresetListActivity : + BaseActivity(), + View.OnClickListener, + SourcePresetListener { + + private val viewModel by viewModels() + private lateinit var adapter: SourcePresetAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityPresetListBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) + + adapter = SourcePresetAdapter(this) + adapter.activePresetId = viewModel.activePresetId + viewBinding.recyclerView.adapter = adapter + viewBinding.recyclerView.setHasFixedSize(true) + viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, false)) + viewBinding.fabAdd.setOnClickListener(this) + + viewModel.presets.observe(this) { presets -> + adapter.submitList(presets) + viewBinding.textViewEmpty.isVisible = presets.isEmpty() + } + viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onPresetDeleted.observeEvent(this) { + adapter.activePresetId = viewModel.activePresetId + Snackbar.make(viewBinding.recyclerView, R.string.preset_deleted, Snackbar.LENGTH_SHORT).show() + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.recyclerView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, + ) + viewBinding.fabAdd.updateLayoutParams { + marginEnd = topMargin + barsInsets.end(v) + bottomMargin = topMargin + barsInsets.bottom + } + return insets.consumeAllSystemBarsInsets() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.fab_add -> startActivity(SourcePresetEditActivity.newIntent(this)) + } + } + + override fun onPresetClick(preset: SourcePreset) { + val newId = if (preset.id == viewModel.activePresetId) SourcePresetsRepository.NO_PRESET else preset.id + viewModel.setActivePreset(newId) + adapter.activePresetId = newId + val message = if (newId != SourcePresetsRepository.NO_PRESET) { + R.string.preset_activated + } else { + R.string.preset_deactivated + } + Snackbar.make(viewBinding.recyclerView, message, Snackbar.LENGTH_SHORT).show() + } + + override fun onEditPreset(preset: SourcePreset) { + startActivity(SourcePresetEditActivity.newIntent(this, preset.id)) + } + + override fun onDeletePreset(preset: SourcePreset) { + MaterialAlertDialogBuilder(this) + .setTitle(preset.title) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.remove) { _, _ -> + viewModel.deletePreset(preset.id) + } + .show() + } + + companion object { + + fun newIntent(context: Context) = Intent(context, SourcePresetListActivity::class.java) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListViewModel.kt new file mode 100644 index 0000000000..3e77821d69 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/preset/SourcePresetListViewModel.kt @@ -0,0 +1,45 @@ +package io.github.landwarderer.futon.explore.ui.preset + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.ui.BaseViewModel +import io.github.landwarderer.futon.core.util.ext.MutableEventFlow +import io.github.landwarderer.futon.core.util.ext.call +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import javax.inject.Inject + +@HiltViewModel +class SourcePresetListViewModel @Inject constructor( + private val presetsRepository: SourcePresetsRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val presets: StateFlow> = presetsRepository.observeAll() + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, emptyList()) + + val onPresetDeleted = MutableEventFlow() + + val activePresetId: Long + get() = settings.activeSourcePresetId + + fun setActivePreset(presetId: Long) { + settings.activeSourcePresetId = presetId + } + + fun deletePreset(presetId: Long) { + launchJob(Dispatchers.IO) { + if (settings.activeSourcePresetId == presetId) { + settings.activeSourcePresetId = SourcePresetsRepository.NO_PRESET + } + presetsRepository.deletePreset(presetId) + onPresetDeleted.call(Unit) + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/data/FavouritesDao.kt index 41e949bf0c..08f195d9cf 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/data/FavouritesDao.kt @@ -15,8 +15,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive import org.intellij.lang.annotations.Language +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaQueryBuilder +import io.github.landwarderer.futon.core.db.SqlCondition import io.github.landwarderer.futon.core.db.TABLE_FAVOURITES +import io.github.landwarderer.futon.core.db.contentTypeCondition +import io.github.landwarderer.futon.core.db.librarySearchCondition import io.github.landwarderer.futon.core.db.entity.MangaWithTags import io.github.landwarderer.futon.favourites.domain.model.Cover import io.github.landwarderer.futon.list.domain.ListFilterOption @@ -40,6 +44,23 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") abstract suspend fun searchByTitle(query: String, limit: Int): List + /** + * Free-text match over everything that identifies an entry on the Favourites screen: its title, the + * source it came from, and the category (list) it is filed under. [pattern] must be escaped for + * `ESCAPE '\'`. + */ + @Transaction + @Query( + "SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id " + + "WHERE favourites.deleted_at = 0 AND (" + + "manga.title LIKE :pattern ESCAPE '\\' OR manga.alt_title LIKE :pattern ESCAPE '\\' " + + "OR manga.source IN (:sourceNames) " + + "OR EXISTS(SELECT 1 FROM favourite_categories c WHERE c.category_id = favourites.category_id " + + "AND c.deleted_at = 0 AND c.title LIKE :pattern ESCAPE '\\')" + + ") GROUP BY favourites.manga_id ORDER BY favourites.created_at DESC LIMIT :limit", + ) + abstract suspend fun filter(pattern: String, sourceNames: List, limit: Int): List + @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") abstract suspend fun searchByAuthor(query: String, limit: Int): List @@ -51,8 +72,9 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { fun observeAll( order: ListSortOrder, filterOptions: Set, - limit: Int - ): Flow> = observeAll(0L, order, filterOptions, limit) + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ): Flow> = observeAll(0L, order, filterOptions, limit, search) @Transaction @Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @@ -72,11 +94,13 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { categoryId: Long, order: ListSortOrder, filterOptions: Set, - limit: Int + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, ): Flow> = observeAllImpl( MangaQueryBuilder(TABLE_FAVOURITES, this) .join("LEFT JOIN manga ON favourites.manga_id = manga.manga_id") .where("deleted_at = 0") + .let { if (search.isEmpty()) it else it.where(searchCondition(search)) } .where( if (categoryId != 0L) { "category_id = $categoryId" @@ -92,7 +116,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { ) suspend fun findCovers(categoryId: Long, order: ListSortOrder): List { - val orderBy = getOrderBy(order) + val orderBy = getOrderBy(order, isGrouped = false) @Language("RoomSql") val query = SimpleSQLiteQuery( @@ -143,6 +167,28 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List + @Query( + """SELECT tags.title FROM tags + INNER JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + INNER JOIN favourites ON favourites.manga_id = manga_tags.manga_id + WHERE favourites.deleted_at = 0 + GROUP BY tags.title + ORDER BY COUNT(DISTINCT manga_tags.manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findPopularTagTitles(limit: Int): List + + @Query( + """SELECT tags.title FROM tags + INNER JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + INNER JOIN favourites ON favourites.manga_id = manga_tags.manga_id + WHERE favourites.category_id = :categoryId AND favourites.deleted_at = 0 + GROUP BY tags.title + ORDER BY COUNT(DISTINCT manga_tags.manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findPopularTagTitles(categoryId: Long, limit: Int): List + fun dump(): Flow = flow { val window = 10 var offset = 0 @@ -214,7 +260,28 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0") protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long) - private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) { + @Query("UPDATE favourites SET pinned = :isPinned WHERE manga_id = :mangaId AND category_id = :categoryId AND deleted_at = 0") + abstract suspend fun setPinned(mangaId: Long, categoryId: Long, isPinned: Boolean) + + @Query("UPDATE favourites SET pinned = :isPinned WHERE manga_id = :mangaId AND deleted_at = 0") + abstract suspend fun setPinned(mangaId: Long, isPinned: Boolean) + + @Query("SELECT DISTINCT manga_id FROM favourites WHERE category_id = :categoryId AND pinned = 1 AND deleted_at = 0") + abstract suspend fun findPinnedIds(categoryId: Long): List + + @Query("SELECT DISTINCT manga_id FROM favourites WHERE pinned = 1 AND deleted_at = 0") + abstract suspend fun findAllPinnedIds(): List + + /** + * Pinned entries always come first. Queries grouped by manga (one row per category) sort on + * MAX(pinned), so an entry pinned in any of its categories is consistently treated as pinned + * instead of depending on which row SQLite happens to pick for the group. + */ + private fun getOrderBy(sortOrder: ListSortOrder, isGrouped: Boolean = true) = (if (isGrouped) { + "MAX(favourites.pinned) DESC, " + } else { + "favourites.pinned DESC, " + }) + when (sortOrder) { ListSortOrder.RATING -> "manga.rating DESC" ListSortOrder.NEWEST -> "favourites.created_at DESC" ListSortOrder.OLDEST -> "favourites.created_at ASC" @@ -230,13 +297,22 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") } + /** Matches anything that identifies an entry on this screen: title, source, or the list it is filed under. */ + private fun searchCondition(search: LibrarySearchQuery): SqlCondition = librarySearchCondition( + query = search, + categoryMatch = "EXISTS(SELECT 1 FROM favourite_categories c WHERE c.category_id = favourites.category_id " + + "AND c.deleted_at = 0 AND c.title LIKE ? ESCAPE '\\')", + ) + override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= $PROGRESS_COMPLETED)" ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})" + is ListFilterOption.TagTitle -> "EXISTS(SELECT * FROM manga_tags INNER JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE favourites.manga_id = manga_tags.manga_id AND tags.title = ${sqlEscapeString(option.titleText)})" ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)" is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}" + is ListFilterOption.ContentType -> contentTypeCondition(option.contentType) else -> null } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavoritesListQuickFilter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavoritesListQuickFilter.kt index 8cccec8b15..d9ddfb4b05 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavoritesListQuickFilter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavoritesListQuickFilter.kt @@ -7,6 +7,7 @@ import io.github.landwarderer.futon.core.os.NetworkState import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.list.domain.ListFilterOption import io.github.landwarderer.futon.list.domain.MangaListQuickFilter +import org.koitharu.kotatsu.parsers.model.ContentType class FavoritesListQuickFilter @AssistedInject constructor( @Assisted private val categoryId: Long, @@ -25,11 +26,26 @@ class FavoritesListQuickFilter @AssistedInject constructor( add(ListFilterOption.Macro.NEW_CHAPTERS) } add(ListFilterOption.Macro.COMPLETED) + add(ListFilterOption.ContentType(ContentType.MANGA)) + add(ListFilterOption.ContentType(ContentType.MANHWA)) + add(ListFilterOption.ContentType(ContentType.MANHUA)) + // Blacklisted tags are ones the user asked not to see; do not advertise them as quick filters. + val blacklist = settings.tagsBlacklist.mapTo(HashSet()) { it.lowercase() } + repository.findPopularTagTitles(categoryId, POPULAR_TAGS_COUNT + blacklist.size) + .asSequence() + .filterNot { it.lowercase() in blacklist } + .take(POPULAR_TAGS_COUNT) + .mapTo(this) { ListFilterOption.TagTitle(it) } repository.findPopularSources(categoryId, 3).mapTo(this) { ListFilterOption.Source(it) } } + private companion object { + + const val POPULAR_TAGS_COUNT = 3 + } + @AssistedFactory interface Factory { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavouritesRepository.kt index 3cfbe98bac..02a8c43eb4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/FavouritesRepository.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.TABLE_FAVOURITES import io.github.landwarderer.futon.core.db.TABLE_FAVOURITE_CATEGORIES @@ -60,11 +61,16 @@ class FavouritesRepository @Inject constructor( return entities.toMangaList() } - fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { + fun observeAll( + order: ListSortOrder, + filterOptions: Set, + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(order, filterOptions, limit) + return localObserver.observeAll(order, filterOptions, limit, search) } - return db.getFavouritesDao().observeAll(order, filterOptions, limit) + return db.getFavouritesDao().observeAll(order, filterOptions, limit, search) .map { it.toMangaList() } } @@ -77,18 +83,24 @@ class FavouritesRepository @Inject constructor( categoryId: Long, order: ListSortOrder, filterOptions: Set, - limit: Int + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(categoryId, order, filterOptions, limit) + return localObserver.observeAll(categoryId, order, filterOptions, limit, search) } - return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit) + return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit, search) .map { it.toMangaList() } } - fun observeAll(categoryId: Long, filterOptions: Set, limit: Int): Flow> { + fun observeAll( + categoryId: Long, + filterOptions: Set, + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ): Flow> { return observeOrder(categoryId) - .flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit) } + .flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit, search) } } fun observeMangaCount(): Flow { @@ -170,6 +182,16 @@ class FavouritesRepository @Inject constructor( }.toMangaSources() } + suspend fun findPopularTagTitles(categoryId: Long, limit: Int): List { + return db.getFavouritesDao().run { + if (categoryId == 0L) { + findPopularTagTitles(limit) + } else { + findPopularTagTitles(categoryId, limit) + } + } + } + suspend fun createCategory( title: String, sortOrder: ListSortOrder, @@ -271,6 +293,28 @@ class FavouritesRepository @Inject constructor( return ReversibleHandle { recoverToCategory(categoryId, ids) } } + suspend fun setPinned(mangaIds: Collection, categoryId: Long, isPinned: Boolean) { + db.withTransaction { + val dao = db.getFavouritesDao() + for (id in mangaIds) { + if (categoryId == 0L) { + dao.setPinned(id, isPinned) + } else { + dao.setPinned(id, categoryId, isPinned) + } + } + } + } + + suspend fun getPinnedIds(categoryId: Long): Set { + val dao = db.getFavouritesDao() + return if (categoryId == 0L) { + dao.findAllPinnedIds().toSet() + } else { + dao.findPinnedIds(categoryId).toSet() + } + } + private fun observeOrder(categoryId: Long): Flow { return db.getFavouriteCategoriesDao().observe(categoryId) .filterNotNull() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/LocalFavoritesObserver.kt index 91f2db45cd..087d2c9a2f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/LocalFavoritesObserver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/domain/LocalFavoritesObserver.kt @@ -2,6 +2,7 @@ package io.github.landwarderer.futon.favourites.domain import dagger.Reusable import kotlinx.coroutines.flow.Flow +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.entity.toManga import io.github.landwarderer.futon.core.db.entity.toMangaTags @@ -22,15 +23,17 @@ class LocalFavoritesObserver @Inject constructor( fun observeAll( order: ListSortOrder, filterOptions: Set, - limit: Int - ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit).mapToLocal() + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ): Flow> = db.getFavouritesDao().observeAll(order, filterOptions, limit, search).mapToLocal() fun observeAll( categoryId: Long, order: ListSortOrder, filterOptions: Set, - limit: Int - ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal() + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit, search).mapToLocal() override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags(), null) 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/FavouritesListFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListFragment.kt index 975908a7e1..2b8698641a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/favourites/ui/list/FavouritesListFragment.kt @@ -65,6 +65,18 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { + R.id.action_pin -> { + viewModel.setPinned(selectedItemsIds, isPinned = true) + mode?.finish() + true + } + + R.id.action_unpin -> { + viewModel.setPinned(selectedItemsIds, isPinned = false) + mode?.finish() + true + } + R.id.action_remove -> { viewModel.removeFromFavourites(selectedItemsIds) mode?.finish() 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..ec4b44b2ff 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 @@ -12,11 +12,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.model.isNovel +import io.github.landwarderer.futon.core.db.SourceSearchFilter import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.prefs.AppSettings @@ -38,6 +40,8 @@ import io.github.landwarderer.futon.list.ui.model.EmptyState import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.list.ui.model.toErrorState +import io.github.landwarderer.futon.search.domain.ScreenSearchQuery +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionScope import org.koitharu.kotatsu.parsers.model.Manga import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -54,6 +58,8 @@ class FavouritesListViewModel @Inject constructor( private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, quickFilterFactory: FavoritesListQuickFilter.Factory, + private val screenSearchQuery: ScreenSearchQuery, + private val sourceSearchFilter: SourceSearchFilter, settings: AppSettings, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, @@ -64,6 +70,8 @@ class FavouritesListViewModel @Inject constructor( private val refreshTrigger = MutableStateFlow(Any()) private val limit = MutableStateFlow(PAGE_SIZE) private val isPaginationReady = AtomicBoolean(false) + private val searchQuery = screenSearchQuery.query(SearchSuggestionScope.FAVOURITES) + .mapLatest { sourceSearchFilter.resolve(it) } override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode } .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, settings.favoritesListMode) @@ -92,7 +100,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() @@ -105,7 +113,10 @@ class FavouritesListViewModel @Inject constructor( override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option) - override fun clearFilter() = quickFilter.clearFilter() + override fun clearFilter() { + screenSearchQuery.clear(SearchSuggestionScope.FAVOURITES) + quickFilter.clearFilter() + } fun markAsRead(items: Set) { launchLoadingJob(Dispatchers.IO) { @@ -114,6 +125,15 @@ class FavouritesListViewModel @Inject constructor( } } + fun setPinned(ids: Set, isPinned: Boolean) { + if (ids.isEmpty()) { + return + } + launchJob(Dispatchers.IO) { + repository.setPinned(ids, categoryId, isPinned) + } + } + fun removeFromFavourites(ids: Set) { if (ids.isEmpty()) { return @@ -145,15 +165,20 @@ class FavouritesListViewModel @Inject constructor( private suspend fun List.mapList(mode: ListMode, filters: Set): List { if (isEmpty()) { - return if (filters.isEmpty()) { + // A text filter counts as a filter: the "no favourites yet" copy would otherwise suggest the + // list is empty rather than that nothing matched. + val hasFilters = filters.isNotEmpty() || + screenSearchQuery.query(SearchSuggestionScope.FAVOURITES).value.isNotEmpty() + return if (!hasFilters) { listOf(getEmptyState(hasFilters = false)) } else { listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) } } + val pinnedIds = repository.getPinnedIds(categoryId) val result = ArrayList(size + 1) quickFilter.filterItem(filters)?.let(result::add) - mangaListMapper.toListModelList(result, this, mode, MangaListMapper.NO_FAVORITE) + mangaListMapper.toListModelList(result, this, mode, MangaListMapper.NO_FAVORITE, pinnedIds) return result } @@ -162,13 +187,18 @@ class FavouritesListViewModel @Inject constructor( sortOrder.filterNotNull(), quickFilter.appliedOptions.combineWithSettings(), limit, - ) { order, filters, limit -> + searchQuery, + ) { order, filters, limit, search -> isPaginationReady.set(false) - repository.observeAll(order, filters, limit) + repository.observeAll(order, filters, limit, search) }.flattenLatest() } else { - combine(quickFilter.appliedOptions.combineWithSettings(), limit) { filters, limit -> - repository.observeAll(categoryId, filters, limit) + combine( + quickFilter.appliedOptions.combineWithSettings(), + limit, + searchQuery, + ) { filters, limit, search -> + repository.observeAll(categoryId, filters, limit, search) }.flattenLatest() } 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/data/HistoryDao.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryDao.kt index 8579fef716..77d5a13770 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryDao.kt @@ -12,7 +12,10 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaQueryBuilder +import io.github.landwarderer.futon.core.db.SqlCondition +import io.github.landwarderer.futon.core.db.librarySearchCondition import io.github.landwarderer.futon.core.db.TABLE_HISTORY import io.github.landwarderer.futon.core.db.entity.MangaWithTags import io.github.landwarderer.futon.core.db.entity.TagEntity @@ -31,6 +34,23 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") abstract suspend fun searchByTitle(query: String, limit: Int): List + /** + * Free-text match over everything that identifies an entry on the History screen: its title, the + * source it came from, and the favourites list it belongs to. [pattern] must be escaped for + * `ESCAPE '\'`. + */ + @Transaction + @Query( + "SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id " + + "WHERE history.deleted_at = 0 AND (" + + "manga.title LIKE :pattern ESCAPE '\\' OR manga.alt_title LIKE :pattern ESCAPE '\\' " + + "OR manga.source IN (:sourceNames) " + + "OR EXISTS(SELECT 1 FROM favourites f INNER JOIN favourite_categories c ON c.category_id = f.category_id " + + "WHERE f.manga_id = history.manga_id AND f.deleted_at = 0 AND c.deleted_at = 0 AND c.title LIKE :pattern ESCAPE '\\')" + + ") GROUP BY history.manga_id ORDER BY history.updated_at DESC LIMIT :limit", + ) + abstract suspend fun filter(pattern: String, sourceNames: List, limit: Int): List + @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") abstract suspend fun searchByAuthor(query: String, limit: Int): List @@ -50,11 +70,13 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { fun observeAll( order: ListSortOrder, filterOptions: Set, - limit: Int + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, ): Flow> = observeAllImpl( MangaQueryBuilder(TABLE_HISTORY, this) .join("LEFT JOIN manga ON history.manga_id = manga.manga_id") .where("history.deleted_at = 0") + .let { if (search.isEmpty()) it else it.where(searchCondition(search)) } .filters(filterOptions) .orderBy( orderBy = when (order) { @@ -190,6 +212,13 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @RawQuery(observedEntities = [HistoryEntity::class]) protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> + /** Matches anything that identifies an entry on this screen: title, source, or the favourites list it is in. */ + private fun searchCondition(search: LibrarySearchQuery): SqlCondition = librarySearchCondition( + query = search, + categoryMatch = "EXISTS(SELECT 1 FROM favourites f INNER JOIN favourite_categories c ON c.category_id = f.category_id " + + "WHERE f.manga_id = history.manga_id AND f.deleted_at = 0 AND c.deleted_at = 0 AND c.title LIKE ? ESCAPE '\\')", + ) + override fun getCondition(option: ListFilterOption): String? = when (option) { is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id AND category_id = ${option.category.id})" ListFilterOption.Macro.COMPLETED -> "percent >= $PROGRESS_COMPLETED" diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryLocalObserver.kt index aa37473320..5216a3477d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryLocalObserver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/data/HistoryLocalObserver.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.history.data import dagger.Reusable +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.entity.toManga import io.github.landwarderer.futon.core.db.entity.toMangaTags @@ -21,8 +22,9 @@ class HistoryLocalObserver @Inject constructor( fun observeAll( order: ListSortOrder, filterOptions: Set, - limit: Int - ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapToLocal() + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, + ) = db.getHistoryDao().observeAll(order, filterOptions, limit, search).mapToLocal() override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags(), null) 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..8203dddb3d 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 @@ -5,6 +5,7 @@ import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import io.github.landwarderer.futon.core.db.LibrarySearchQuery import io.github.landwarderer.futon.core.db.MangaDatabase import io.github.landwarderer.futon.core.db.entity.toEntity import io.github.landwarderer.futon.core.db.entity.toManga @@ -12,6 +13,8 @@ import io.github.landwarderer.futon.core.db.entity.toMangaList import io.github.landwarderer.futon.core.db.entity.toMangaTags import io.github.landwarderer.futon.core.db.entity.toMangaTagsList import io.github.landwarderer.futon.core.model.MangaHistory +import io.github.landwarderer.futon.core.model.findSameChapter +import io.github.landwarderer.futon.core.model.getPreferredBranch import io.github.landwarderer.futon.core.model.isLocal import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.model.toMangaSources @@ -25,8 +28,10 @@ import io.github.landwarderer.futon.list.domain.ListFilterOption import io.github.landwarderer.futon.list.domain.ListSortOrder import io.github.landwarderer.futon.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter 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 @@ -34,6 +39,8 @@ import io.github.landwarderer.futon.scrobbling.common.domain.tryScrobble import io.github.landwarderer.futon.search.domain.SearchKind import io.github.landwarderer.futon.tracker.domain.CheckNewChaptersUseCase import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.ceil import javax.inject.Provider @Reusable @@ -92,12 +99,13 @@ class HistoryRepository @Inject constructor( fun observeAllWithHistory( order: ListSortOrder, filterOptions: Set, - limit: Int + limit: Int, + search: LibrarySearchQuery = LibrarySearchQuery.EMPTY, ): Flow> { if (ListFilterOption.Downloaded in filterOptions) { - return localObserver.observeAll(order, filterOptions, limit) + return localObserver.observeAll(order, filterOptions, limit, search) } - return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems { + return db.getHistoryDao().observeAll(order, filterOptions, limit, search).mapItems { MangaWithHistory( it.toManga(), it.history.toMangaHistory(), @@ -116,15 +124,19 @@ class HistoryRepository @Inject constructor( return } assert(manga.chapters != null) + // A reader opened on an older load can still be on an id that the source has replaced since + val currentChapterId = manga.chapters?.takeIf { it.findById(chapterId) == null } + ?.let { chapters -> mangaRepository.findCachedChapter(manga.id, chapterId)?.let(chapters::findSameChapter) } + ?.id ?: chapterId db.withTransaction { mangaRepository.storeManga(manga, replaceExisting = true) - val branch = manga.chapters?.findById(chapterId)?.branch + val branch = manga.chapters?.findById(currentChapterId)?.branch db.getHistoryDao().upsert( HistoryEntity( mangaId = manga.id, createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(), - chapterId = chapterId, + chapterId = currentChapterId, page = page, scroll = scroll.toFloat(), // we migrate to int, but decide to not update database percent = percent, @@ -132,8 +144,8 @@ class HistoryRepository @Inject constructor( deletedAt = 0L, ), ) - newChaptersUseCaseProvider.get()(manga, chapterId) - scrobblers.forEach { it.tryScrobble(manga, chapterId) } + newChaptersUseCaseProvider.get()(manga, currentChapterId) + scrobblers.forEach { it.tryScrobble(manga, currentChapterId) } } } @@ -141,6 +153,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 @@ -217,17 +248,84 @@ class HistoryRepository @Inject constructor( } private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { - val chapters = manga.chapters - if (manga.isLocal || chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) { + val allChapters = manga.chapters + // The history chapter can be in any branch: only a chapter gone from the whole list needs recovering. + if (manga.isLocal || allChapters.isNullOrEmpty() || allChapters.findById(chapterId) != null) { return this } - val newChapterId = chapters.getOrNull( - (chapters.size * percent).toInt(), - )?.id ?: return this - val newEntity = copy(chapterId = newChapterId) + // The source gave the chapter a new id: the stored list still says which chapter it was + val previous = mangaRepository.findCachedChapter(manga.id, chapterId) + val same = previous?.let(allChapters::findSameChapter) + if (same != null) { + val newEntity = copy(chapterId = same.id) + db.getHistoryDao().update(newEntity) + return newEntity + } + // Otherwise guess the position from the percentage, in the branch the chapter was read in if it still exists + val chapters = previous?.let { manga.getChapters(it.branch) }?.takeUnless { it.isEmpty() } + ?: manga.findRecoveryChapters(this) + if (chapters.isNullOrEmpty()) { + return this + } + val newChapterIndex = estimateRecoveredChapterIndex(chapters.size) ?: return this + val newChapterId = chapters.getOrNull(newChapterIndex)?.id ?: return this + val newEntity = copy( + chapterId = newChapterId, + percent = estimateRecoveredPercent(chapters.size), + chaptersCount = chapters.size, + ) db.getHistoryDao().update(newEntity) return newEntity } + private fun Manga.findRecoveryChapters(history: HistoryEntity): List? { + val allChapters = chapters?.takeUnless { it.isEmpty() } ?: return null + if (history.chaptersCount > 0) { + return allChapters.groupBy { it.branch } + .values + .minByOrNull { abs(it.size - history.chaptersCount) } + } + return getChapters(getPreferredBranch(null)).takeUnless { it.isEmpty() } ?: allChapters + } + + private fun HistoryEntity.estimateRecoveredChapterIndex(newChaptersCount: Int): Int? { + if (newChaptersCount <= 0 || !ReadingProgress.isValid(percent)) { + return null + } + val referenceCount = chaptersCount.takeIf { it > 0 } ?: newChaptersCount + val referenceIndex = if (ReadingProgress.isCompleted(percent)) { + referenceCount - 1 + } else { + // percent counts the page currently on screen as read, so a value on an exact + // chapter boundary is ambiguous: "finished chapter i" or "still inside chapter i" + // (single-page long-strip chapters always sit on the boundary). Resolve to + // chapter i - never skip ahead - the preserved page/scroll belong to it anyway. + ceil(percent.toDouble() * referenceCount).toInt() - 1 + } + return referenceIndex.coerceIn(0, newChaptersCount - 1) + } + + private fun HistoryEntity.estimateRecoveredPercent(newChaptersCount: Int): Float { + if (chaptersCount <= 0 || newChaptersCount <= 0 || !ReadingProgress.isValid(percent)) { + return percent + } + return (percent * chaptersCount / newChaptersCount).coerceIn(0f, 1f) + } + 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/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/HistoryListViewModel.kt index b3e05a0138..71cb2cbae2 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 @@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.db.SourceSearchFilter import io.github.landwarderer.futon.core.model.MangaHistory import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.parser.MangaDataRepository @@ -40,6 +42,8 @@ import io.github.landwarderer.futon.list.ui.model.ListHeader import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState import io.github.landwarderer.futon.list.ui.model.toErrorState +import io.github.landwarderer.futon.search.domain.ScreenSearchQuery +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionScope import org.koitharu.kotatsu.parsers.model.Manga import java.time.Instant import java.util.concurrent.atomic.AtomicBoolean @@ -57,6 +61,8 @@ class HistoryListViewModel @Inject constructor( private val mangaListMapper: MangaListMapper, private val markAsReadUseCase: MarkAsReadUseCase, private val quickFilter: HistoryListQuickFilter, + private val screenSearchQuery: ScreenSearchQuery, + private val sourceSearchFilter: SourceSearchFilter, mangaDataRepository: MangaDataRepository, @LocalStorageChanges localStorageChanges: SharedFlow, ) : MangaListViewModel(settings, mangaDataRepository, localStorageChanges), QuickFilterListener by quickFilter { @@ -82,6 +88,8 @@ class HistoryListViewModel @Inject constructor( private val limit = MutableStateFlow(PAGE_SIZE) private val isPaginationReady = AtomicBoolean(false) + private val searchQuery = screenSearchQuery.query(SearchSuggestionScope.HISTORY) + .mapLatest { sourceSearchFilter.resolve(it) } val isStatsEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.IO, @@ -109,7 +117,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 @@ -161,11 +169,17 @@ class HistoryListViewModel @Inject constructor( sortOrder, quickFilter.appliedOptions.combineWithSettings(), limit, - ) { order, filters, limit -> + searchQuery, + ) { order, filters, limit, search -> isPaginationReady.set(false) - repository.observeAllWithHistory(order, filters, limit) + repository.observeAllWithHistory(order, filters, limit, search) }.flattenLatest() + override fun clearFilter() { + screenSearchQuery.clear(SearchSuggestionScope.HISTORY) + quickFilter.clearFilter() + } + private suspend fun mapList( list: List, grouped: Boolean, @@ -174,7 +188,11 @@ class HistoryListViewModel @Inject constructor( isIncognito: Boolean, ): List { if (list.isEmpty()) { - return if (filters.isEmpty()) { + // A text filter counts as a filter: showing "what you read will appear here" to someone who + // just searched reads as if their history was wiped. + val hasFilters = filters.isNotEmpty() || + screenSearchQuery.query(SearchSuggestionScope.HISTORY).value.isNotEmpty() + return if (!hasFilters) { listOf(getEmptyState(hasFilters = false)) } else { listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/util/ReadingProgressView.kt b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/util/ReadingProgressView.kt index a31c8817ea..f1e2280e84 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/util/ReadingProgressView.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/history/ui/util/ReadingProgressView.kt @@ -44,10 +44,10 @@ class ReadingProgressView @JvmOverloads constructor( NONE -> "" PERCENT_READ -> percentPattern.format(ReadingProgress.percentToString(value.percent)) - PERCENT_LEFT -> "-" + percentPattern.format(ReadingProgress.percentToString(value.percentLeft)) + PERCENT_LEFT -> percentPattern.format(ReadingProgress.percentToString(value.percentLeft)) CHAPTERS_READ -> value.chapters.toString() - CHAPTERS_LEFT -> "-" + value.chaptersLeft.toString() + CHAPTERS_LEFT -> value.chaptersLeft.toString() } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/ListFilterOption.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/ListFilterOption.kt index d1b693f085..ffd61b1b67 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/ListFilterOption.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/ListFilterOption.kt @@ -6,10 +6,12 @@ import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.db.entity.toEntity import io.github.landwarderer.futon.core.model.FavouriteCategory import io.github.landwarderer.futon.core.model.LocalMangaSource +import io.github.landwarderer.futon.core.model.titleResId import io.github.landwarderer.futon.core.model.unwrap import io.github.landwarderer.futon.core.parser.external.ExternalMangaSource import io.github.landwarderer.futon.core.parser.favicon.faviconUri import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.ContentType as MangaContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag @@ -95,6 +97,24 @@ sealed interface ListFilterOption { get() = "_tag" } + /** + * A tag selected by title rather than id: the same genre from different sources is stored as + * different tags, and this matches all of them. + */ + data class TagTitle( + override val titleText: String, + ) : ListFilterOption { + + override val titleResId: Int + get() = 0 + + override val iconResId: Int + get() = R.drawable.ic_tag + + override val groupKey: String + get() = "_tag" + } + data class Favorite( val category: FavouriteCategory ) : ListFilterOption { @@ -137,6 +157,23 @@ sealed interface ListFilterOption { override fun getIconData() = mangaSource.faviconUri() } + data class ContentType( + val contentType: MangaContentType, + ) : ListFilterOption { + + override val titleResId: Int + get() = contentType.titleResId + + override val iconResId: Int + get() = R.drawable.ic_book_page + + override val titleText: CharSequence? + get() = null + + override val groupKey: String + get() = "_content_type" + } + data class Inverted( val option: ListFilterOption, override val iconResId: Int, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/MangaListMapper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/MangaListMapper.kt index 29fa9e6623..67efecd984 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/MangaListMapper.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/domain/MangaListMapper.kt @@ -56,11 +56,12 @@ class MangaListMapper @Inject constructor( manga: Collection, mode: ListMode, @Flags flags: Int = DEFAULTS, + pinnedIds: Set = emptySet(), ) { val options = getOptions(flags) val overrides = dataRepository.getOverrides() manga.mapTo(destination) { - toListModelImpl(it, mode, options, overrides[it.id]) + toListModelImpl(it, mode, options, overrides[it.id], it.id in pinnedIds) } } @@ -106,6 +107,7 @@ class MangaListMapper @Inject constructor( manga: Manga, @Options options: Int, override: MangaOverride?, + isPinned: Boolean = false, ) = MangaDetailedListModel( subtitle = manga.altTitles.firstOrNull(), manga = manga, @@ -115,12 +117,14 @@ class MangaListMapper @Inject constructor( isFavorite = isFavorite(manga.id, options), isSaved = isSaved(manga.id, options), tags = mapTags(manga.tags), + isPinned = isPinned, ) private suspend fun toGridModel( manga: Manga, @Options options: Int, - override: MangaOverride? + override: MangaOverride?, + isPinned: Boolean = false, ) = MangaGridModel( manga = manga, override = override, @@ -128,6 +132,7 @@ class MangaListMapper @Inject constructor( progress = getProgress(manga.id, options), isFavorite = isFavorite(manga.id, options), isSaved = isSaved(manga.id, options), + isPinned = isPinned, ) private suspend fun toListModelImpl( @@ -135,10 +140,11 @@ class MangaListMapper @Inject constructor( mode: ListMode, @Options options: Int, override: MangaOverride?, + isPinned: Boolean = false, ): MangaListModel = when (mode) { ListMode.LIST -> toCompactListModel(manga, options, override) - ListMode.DETAILED_LIST -> toDetailedListModel(manga, options, override) - ListMode.GRID -> toGridModel(manga, options, override) + ListMode.DETAILED_LIST -> toDetailedListModel(manga, options, override, isPinned) + ListMode.GRID -> toGridModel(manga, options, override, isPinned) } private suspend fun getCounter(mangaId: Long, @Options options: Int): Int { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListFragment.kt index 081cf468c6..cee7e1c097 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/MangaListFragment.kt @@ -18,7 +18,6 @@ import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import io.github.landwarderer.futon.R -import io.github.landwarderer.futon.alternatives.ui.AutoFixService import io.github.landwarderer.futon.core.exceptions.resolve.ExceptionResolver import io.github.landwarderer.futon.core.exceptions.resolve.SnackbarErrorObserver import io.github.landwarderer.futon.core.model.isLocal @@ -26,7 +25,6 @@ import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ListMode import io.github.landwarderer.futon.core.ui.BaseFragment -import io.github.landwarderer.futon.core.ui.dialog.buildAlertDialog import io.github.landwarderer.futon.core.ui.list.FitHeightGridLayoutManager import io.github.landwarderer.futon.core.ui.list.FitHeightLinearLayoutManager import io.github.landwarderer.futon.core.ui.list.ListSelectionController @@ -274,9 +272,10 @@ abstract class MangaListFragment : @CallSuper override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { val hasNoLocal = selectedItems.none { it.isLocal } + val hasSingleSource = selectedItems.distinctBy { it.source.name }.size == 1 val isSingleSelection = controller.count == 1 menu.findItem(R.id.action_save)?.isVisible = hasNoLocal - menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal + menu.findItem(R.id.action_replace_source)?.isVisible = hasNoLocal && hasSingleSource menu.findItem(R.id.action_edit_override)?.isVisible = isSingleSelection return super.onPrepareActionMode(controller, mode, menu) } @@ -323,18 +322,9 @@ abstract class MangaListFragment : true } - R.id.action_fix -> { - val itemsSnapshot = selectedItemsIds - buildAlertDialog(context ?: return false, isCentered = true) { - setTitle(item.title) - setIcon(item.icon) - setMessage(R.string.manga_fix_prompt) - setNegativeButton(android.R.string.cancel, null) - setPositiveButton(R.string.fix) { _, _ -> - AutoFixService.start(context, itemsSnapshot) - mode?.finish() - } - }.show() + R.id.action_replace_source -> { + router.openSourceReplacement(selectedItems.toList()) + mode?.finish() true } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt index ffc8b29114..0c62e948c7 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/ListItemType.kt @@ -37,4 +37,5 @@ enum class ListItemType { EXTENSION, EXTENSION_REPO_HEADER, EXTENSION_REPO_ITEM, + SOURCE_REPLACEMENT, } 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/adapter/MangaGridItemAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaGridItemAD.kt index 1778f43e88..70df415358 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaGridItemAD.kt @@ -29,6 +29,7 @@ fun mangaGridItemAD( binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads) with(binding.iconsView) { clearIcons() + if (item.isPinned) addIcon(R.drawable.ic_pin_small) if (item.isSaved) addIcon(R.drawable.ic_storage) if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) isVisible = iconsCount > 0 diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaListDetailedItemAD.kt index 2dd9999a5d..ce1f17d89e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/MangaListDetailedItemAD.kt @@ -28,6 +28,7 @@ fun mangaListDetailedItemAD( ) with(binding.iconsView) { clearIcons() + if (item.isPinned) addIcon(R.drawable.ic_pin_small) if (item.isSaved) addIcon(R.drawable.ic_storage) if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) isVisible = iconsCount > 0 diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt index 2b92d52cb6..2aa7f77610 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/adapter/TypedListSpacingDecoration.kt @@ -40,6 +40,7 @@ class TypedListSpacingDecoration( ListItemType.EXTENSION_REPO_ITEM, ListItemType.FEED, ListItemType.EXPLORE_SOURCE_LIST, + ListItemType.SOURCE_REPLACEMENT, ListItemType.MANGA_SCROBBLING, ListItemType.MANGA_LIST, -> outRect.set(0) 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/list/ui/model/MangaDetailedListModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaDetailedListModel.kt index 104ed41ebc..8f24eee3fc 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaDetailedListModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaDetailedListModel.kt @@ -16,6 +16,7 @@ data class MangaDetailedListModel( val isFavorite: Boolean, val isSaved: Boolean, val tags: List, + val isPinned: Boolean = false, ) : MangaListModel() { override fun getChangePayload(previousState: ListModel): Any? = when { @@ -23,7 +24,8 @@ data class MangaDetailedListModel( previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED previousState.isFavorite != isFavorite || - previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED + previousState.isSaved != isSaved || + previousState.isPinned != isPinned -> PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaGridModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaGridModel.kt index 877682241b..b9a082387c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaGridModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/list/ui/model/MangaGridModel.kt @@ -13,6 +13,7 @@ data class MangaGridModel( val progress: ReadingProgress?, val isFavorite: Boolean, val isSaved: Boolean, + val isPinned: Boolean = false, ) : MangaListModel() { override fun getChangePayload(previousState: ListModel): Any? = when { @@ -20,7 +21,8 @@ data class MangaGridModel( previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED previousState.isFavorite != isFavorite || - previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED + previousState.isSaved != isSaved || + previousState.isPinned != isPinned -> PAYLOAD_ANYTHING_CHANGED else -> super.getChangePayload(previousState) } 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()) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt index f506cab279..f50081de59 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt @@ -71,6 +71,8 @@ import io.github.landwarderer.futon.remotelist.ui.MangaSearchMenuProvider import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionItemCallback import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionListenerImpl import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionMenuProvider +import io.github.landwarderer.futon.search.domain.ScreenSearchQuery +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionScope import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionViewModel import io.github.landwarderer.futon.search.ui.suggestion.adapter.SearchSuggestionAdapter import kotlinx.coroutines.Dispatchers @@ -99,6 +101,12 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav private val viewModel by viewModels() private val searchSuggestionViewModel by viewModels() + + @Inject + lateinit var screenSearchQuery: ScreenSearchQuery + + /** Mirrors the filter of the screen currently shown, so the menu can be prepared synchronously. */ + private var activeScreenFilter: String = "" private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result -> if (result != null) { viewBinding.searchView.setText(result) @@ -207,6 +215,15 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { adjustFabVisibility(topFragment = fragment) adjustAppbar(topFragment = fragment) + // The search bar is shared by every section, so tell it which one it now belongs to: on History + // and Favourites it can narrow to that screen instead of suggesting across the whole library. + searchSuggestionViewModel.setScope( + when (fragment) { + is HistoryListFragment -> SearchSuggestionScope.HISTORY + is FavouritesContainerFragment -> SearchSuggestionScope.FAVOURITES + else -> SearchSuggestionScope.ALL + }, + ) if (fromUser) { actionModeDelegate.finishActionMode() viewBinding.appbar.setExpanded(true) @@ -413,6 +430,29 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav private fun initSearch() { val listener = SearchSuggestionListenerImpl(router, viewBinding.searchView, searchSuggestionViewModel) + // Keep the collapsed bar showing the active filter, and offer a way out of it. The SearchView and + // SearchBar hold their own text, so hiding the overlay does not carry the query across by itself. + addMenuProvider( + ScreenFilterMenuProvider( + isFilterActive = { activeScreenFilter.isNotEmpty() }, + onClear = { + viewBinding.searchView.setText("") + // Dropping the mode clears that screen's filter, so this is the only call needed. + searchSuggestionViewModel.setScoped(false) + }, + ), + ) + screenSearchQuery.activeQuery.observe(this) { query -> + val wasFiltered = activeScreenFilter.isNotEmpty() + activeScreenFilter = query + viewBinding.searchBar.setText(query) + if (wasFiltered && query.isEmpty() && !viewBinding.searchView.isShowing) { + // Cleared from the screen itself (e.g. "Reset filter"): do not leave the stale text in + // the field to be offered again next time search is opened. + viewBinding.searchView.setText("") + } + invalidateOptionsMenu() + } val adapter = SearchSuggestionAdapter(listener) viewBinding.searchView.toolbar.addMenuProvider( SearchSuggestionMenuProvider(this, voiceInputLauncher, searchSuggestionViewModel), diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/ScreenFilterMenuProvider.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/ScreenFilterMenuProvider.kt new file mode 100644 index 0000000000..e5fe97ce54 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/ScreenFilterMenuProvider.kt @@ -0,0 +1,36 @@ +package io.github.landwarderer.futon.main.ui + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import io.github.landwarderer.futon.R + +/** + * Puts a clear button in the search bar while a library screen is filtered. + * + * Without it a filter can only be undone by re-opening search and emptying the field, which is easy + * to miss - the list just looks short. + */ +class ScreenFilterMenuProvider( + private val isFilterActive: () -> Boolean, + private val onClear: () -> Unit, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_screen_filter, menu) + } + + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.action_clear_screen_filter)?.isVisible = isFilterActive() + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_clear_screen_filter -> { + onClear() + true + } + + else -> false + } +} 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..585363ed35 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 { @@ -207,7 +210,7 @@ class PageLoader @Inject constructor( } else { val file = uri.toFile() runInterruptible(Dispatchers.IO) { - context.ensureRamAtLeast(file.length() * 2) + context.ensureRamAtLeast(BitmapDecoderCompat.estimateDecodedSize(file)) BitmapDecoderCompat.decode(file) }.use { image -> image.compressToPNG(file) @@ -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/ReaderActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderActivity.kt index 2822391f80..b58a79336e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/ReaderActivity.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.activity.viewModels +import androidx.annotation.MainThread import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.graphics.Insets import androidx.core.view.WindowInsetsCompat @@ -114,6 +115,11 @@ class ReaderActivity : private var gestureInsets: Insets = Insets.NONE private lateinit var readerManager: ReaderManager private val hideUiRunnable = Runnable { setUiIsVisible(false) } + private val hideEInkFlashRunnable = Runnable { einkFlashView.isVisible = false } + private val eInkPageSettledRunnable = Runnable { onEInkPageSettled() } + private lateinit var einkFlashView: View + private var eInkFlashCounter = 0 + private var eInkFlashLastPage: Pair, ReaderState>? = null // Tracks whether the foldable device is in an unfolded state (half-opened or flat) private var isFoldUnfolded: Boolean = false @@ -122,6 +128,7 @@ class ReaderActivity : super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) + einkFlashView = createEInkFlashView() setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) touchHelper = TapGridDispatcher(viewBinding.root, this) scrollTimer = scrollTimerFactory.create(resources, this, this) @@ -523,6 +530,7 @@ class ReaderActivity : ) { viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION) } + flashOnPageChanged(previous, uiState) if (uiState.isSliderAvailable()) { viewBinding.actionsView.setSliderValue( value = uiState.currentPage, @@ -536,6 +544,71 @@ class ReaderActivity : viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter() } + private fun createEInkFlashView(): View { + return View(this).apply { + isVisible = false + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + viewBinding.root.addView( + this, + CoordinatorLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + } + + /** + * Page changes are only counted once the reader has settled, so that a swipe between two + * double-page spreads (which goes through intermediate visible ranges) or a webtoon scroll + * counts as a single change, and so that restoring the reading position on open is not one. + */ + @MainThread + private fun flashOnPageChanged(previous: ReaderUiState?, uiState: ReaderUiState) { + if (!settings.isEInkFlashEnabled) { + einkFlashView.removeCallbacks(eInkPageSettledRunnable) + eInkFlashCounter = 0 + eInkFlashLastPage = null + return + } + if (previous != null && previous.chapter.id == uiState.chapter.id && previous.currentPage == uiState.currentPage) { + return + } + einkFlashView.removeCallbacks(eInkPageSettledRunnable) + einkFlashView.postDelayed(eInkPageSettledRunnable, EINK_SETTLE_DELAY) + } + + @MainThread + private fun onEInkPageSettled() { + // the reader's own state is per spread in the double-page mode, spacers excluded + val reader = readerManager.currentReader ?: return + val state = reader.getCurrentState()?.copy(scroll = 0) ?: return + val page = reader.javaClass to state + val lastPage = eInkFlashLastPage + eInkFlashLastPage = page + // switching the reader mode re-anchors the position, that is not a page change + if (lastPage == null || lastPage.first != page.first || lastPage == page || !settings.isEInkFlashEnabled) { + return + } + if (readerManager.currentMode == ReaderMode.WEBTOON && scrollTimer.isActive.value) { + return // do not cover a continuously scrolling page + } + eInkFlashCounter++ + if (eInkFlashCounter % settings.eInkFlashEvery == 0) { + showEInkFlash() + } + } + + private fun showEInkFlash() { + einkFlashView.removeCallbacks(hideEInkFlashRunnable) + einkFlashView.setBackgroundColor(settings.eInkFlashColor.colorInt) + einkFlashView.bringToFront() + einkFlashView.isVisible = true + einkFlashView.postDelayed(hideEInkFlashRunnable, settings.eInkFlashDuration.toLong()) + } + private fun updateScrollTimerButton() { val button = viewBinding.buttonTimer ?: return val isButtonVisible = scrollTimer.isActive.value @@ -594,5 +667,6 @@ class ReaderActivity : companion object { private const val TOAST_DURATION = 2000L + private const val EINK_SETTLE_DELAY = 150L } } 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/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/config/ReaderConfigSheet.kt index f341fbd017..bbc422577f 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/config/ReaderConfigSheet.kt @@ -93,6 +93,8 @@ class ReaderConfigSheet : binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled + binding.switchDoubleCoverPage.isChecked = settings.isReaderDoubleCoverPage + binding.switchDoubleCoverPage.isEnabled = binding.switchDoubleReader.isEnabled binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f) binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context)) binding.adjustSensitivitySlider(withAnimation = false) @@ -107,6 +109,7 @@ class ReaderConfigSheet : binding.buttonBookmark.setOnClickListener(this) binding.switchDoubleReader.setOnCheckedChangeListener(this) binding.switchDoubleFoldable.setOnCheckedChangeListener(this) + binding.switchDoubleCoverPage.setOnCheckedChangeListener(this) binding.sliderDoubleSensitivity.addOnChangeListener(this) viewModel.isBookmarkAdded.observe(viewLifecycleOwner) { @@ -191,6 +194,11 @@ class ReaderConfigSheet : // Re-evaluate double-page considering foldable state and current manual toggle findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape) } + + R.id.switch_double_cover_page -> { + // applied by the double page reader observing this setting + settings.isReaderDoubleCoverPage = isChecked + } } } @@ -256,7 +264,8 @@ class ReaderConfigSheet : val needTransition = withAnimation && ( (isSubOptionsVisible != sliderDoubleSensitivity.isVisible) || (isSubOptionsVisible != textDoubleSensitivity.isVisible) || - (isSubOptionsVisible != switchDoubleFoldable.isVisible) + (isSubOptionsVisible != switchDoubleFoldable.isVisible) || + (isSubOptionsVisible != switchDoubleCoverPage.isVisible) ) if (needTransition) { TransitionManager.beginDelayedTransition(layoutMain) @@ -264,6 +273,7 @@ class ReaderConfigSheet : sliderDoubleSensitivity.isVisible = isSubOptionsVisible textDoubleSensitivity.isVisible = isSubOptionsVisible switchDoubleFoldable.isVisible = isSubOptionsVisible + switchDoubleCoverPage.isVisible = isSubOptionsVisible } interface Callback { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageHolder.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageHolder.kt index 5cd9c601b8..df5ca9637a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageHolder.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageHolder.kt @@ -2,7 +2,10 @@ package io.github.landwarderer.futon.reader.ui.pager.doublepage import android.graphics.PointF import android.view.Gravity +import android.view.ViewGroup import android.widget.FrameLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import io.github.landwarderer.futon.core.exceptions.resolve.ExceptionResolver @@ -36,24 +39,92 @@ class DoublePageHolder( binding.ssiv.panLimit = SubsamplingScaleImageView.PAN_LIMIT_INSIDE } + private var isSpacer = false + override fun onBind(data: ReaderPage) { super.onBind(data) + isSpacer = false + resetTransformations() + binding.ssiv.isVisible = true + binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) .gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM } + /** + * Display a blank page, used to pad chapters so that two chapters never share a spread + */ + fun bindSpacer() { + isSpacer = true + viewModel.onRecycle() + binding.ssiv.recycle() + resetTransformations() + binding.ssiv.isVisible = false + binding.textViewNumber.isVisible = false + bindingInfo.layoutProgress.isGone = true + bindingInfo.layoutError.isVisible = false + settings.applyBackground(itemView) + } + + override fun onConfigChanged(settings: ReaderSettings) { + super.onConfigChanged(settings) + if (isSpacer) { + binding.textViewNumber.isVisible = false + } + } + override fun onReady() { with(binding.ssiv) { - maxScale = 2f * maxOf( - width / sWidth.toFloat(), - height / sHeight.toFloat(), - ) - binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() + colorFilter = settings.colorFilter?.toColorFilter() minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE + // no per-page zoom: the whole spread is zoomed by DoublePageScalingFrame + maxScale = minScale setScaleAndCenter( minScale, - PointF(if (isEven) 0f else sWidth.toFloat(), sHeight / 2f), + PointF(sWidth / 2f, sHeight / 2f), ) + if (sWidth.toFloat() / sHeight > WIDE_IMAGE_RATIO) { + // wide image: scale it up to fit the whole spread + val containerWidth = width.toFloat() + val parent = itemView.parent as? ViewGroup + val fullWidth = parent?.width?.toFloat() ?: (containerWidth * 2f) + val fullHeight = parent?.height?.toFloat() ?: height.toFloat() + val fitScale = minOf(fullWidth / sWidth, fullHeight / sHeight) + val ratio = (fitScale / minScale).coerceAtLeast(1f) + scaleX = ratio + scaleY = ratio + translationX = if (isEven) containerWidth / 2f else -containerWidth / 2f + settings.applyBackground(this) + itemView.translationZ = 1f + (itemView as? ViewGroup)?.clipChildren = false + } else { + // regular page: push it toward the other page of the spread + scaleX = 1f + scaleY = 1f + background = null + itemView.translationZ = 0f + val offset = (width - sWidth * minScale) / 2f + translationX = when { + offset <= 0f -> 0f + isEven -> offset + else -> -offset + } + } } } + + private fun resetTransformations() { + with(binding.ssiv) { + scaleX = 1f + scaleY = 1f + translationX = 0f + background = null + } + itemView.translationZ = 0f + } + + private companion object { + + const val WIDE_IMAGE_RATIO = 1.3f + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageList.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageList.kt new file mode 100644 index 0000000000..6c21b69182 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageList.kt @@ -0,0 +1,127 @@ +package io.github.landwarderer.futon.reader.ui.pager.doublepage + +import io.github.landwarderer.futon.reader.ui.pager.ReaderPage + +/** + * Items of the double-page reader adapter: the reader pages padded with blank spacers so that + * two chapters never share a spread (each chapter takes an even number of slots), optionally + * showing the first page of every chapter alone, as a cover. + * + * Adapter positions refer to [items]; original positions refer to the unpadded page list + * handed to the reader (always in natural order, even when [items] are reversed). + * Spreads are the adapter position pairs (2k, 2k + 1). + */ +class DoublePageList private constructor( + val items: List, + private val toOriginal: IntArray, + private val toAdapter: IntArray, +) { + + val size: Int + get() = items.size + + /** Original position of the page at [adapterPosition], or -1 for a spacer or out of bounds. */ + fun toOriginalPosition(adapterPosition: Int): Int = toOriginal.getOrElse(adapterPosition) { -1 } + + /** Adapter position of the page at [originalPosition], or -1 if out of bounds. */ + fun toAdapterPosition(originalPosition: Int): Int = toAdapter.getOrElse(originalPosition) { -1 } + + /** + * Original positions range covered by the adapter positions [firstPosition]..[lastPosition], + * spacers excluded, or null if there are no real pages in it. + */ + fun toOriginalRange(firstPosition: Int, lastPosition: Int): IntRange? { + if (firstPosition < 0 || lastPosition < firstPosition) { + return null + } + var lower = Int.MAX_VALUE + var upper = -1 + for (i in firstPosition..minOf(lastPosition, toOriginal.lastIndex)) { + val pos = toOriginal[i] + if (pos >= 0) { + if (pos < lower) lower = pos + if (pos > upper) upper = pos + } + } + return if (upper < 0) null else lower..upper + } + + /** First real page of the spread containing [adapterPosition], or null. */ + fun getSpreadPage(adapterPosition: Int): ReaderPage? { + if (adapterPosition < 0) { + return null + } + val start = adapterPosition and 1.inv() + return items.getOrNull(start)?.takeUnless { it.isSpacer } + ?: items.getOrNull(start + 1)?.takeUnless { it.isSpacer } + } + + companion object { + + val EMPTY = DoublePageList(emptyList(), IntArray(0), IntArray(0)) + + fun build(pages: List, coverPage: Boolean, reversed: Boolean): DoublePageList { + if (pages.isEmpty()) { + return EMPTY + } + val items = ArrayList(pages.size + pages.size / 8 + 2) + val toOriginal = ArrayList(pages.size + pages.size / 8 + 2) + val toAdapter = IntArray(pages.size) + var chapterStart = 0 + pages.forEachIndexed { i, page -> + val isNewChapter = i == 0 || page.chapterId != pages[i - 1].chapterId + if (isNewChapter) { + if (i != 0) { + padChapter(items, toOriginal, chapterStart, pages[i - 1]) + } + chapterStart = items.size + if (coverPage) { + items += spacer(page, SPACER_LEADING) + toOriginal += -1 + } + } + toAdapter[i] = items.size + items += page + toOriginal += i + } + padChapter(items, toOriginal, chapterStart, pages.last()) + if (reversed) { + items.reverse() + toOriginal.reverse() + val last = items.lastIndex + for (i in toAdapter.indices) { + toAdapter[i] = last - toAdapter[i] + } + } + return DoublePageList(items, toOriginal.toIntArray(), toAdapter) + } + + private fun padChapter( + items: MutableList, + toOriginal: MutableList, + chapterStart: Int, + lastPage: ReaderPage, + ) { + if ((items.size - chapterStart) % 2 != 0) { + items += spacer(lastPage, SPACER_TRAILING) + toOriginal += -1 + } + } + + private fun spacer(page: ReaderPage, kind: Long) = ReaderPage( + id = kind, + url = "", + preview = null, + chapterId = page.chapterId, + index = SPACER_INDEX, + source = page.source, + ) + + private const val SPACER_INDEX = -1 + private const val SPACER_LEADING = Long.MIN_VALUE + private const val SPACER_TRAILING = Long.MIN_VALUE + 1 + } +} + +val ReaderPage.isSpacer: Boolean + get() = index < 0 diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageScalingFrame.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageScalingFrame.kt new file mode 100644 index 0000000000..c04d03e258 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePageScalingFrame.kt @@ -0,0 +1,285 @@ +package io.github.landwarderer.futon.reader.ui.pager.doublepage + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Matrix +import android.graphics.Point +import android.graphics.RectF +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.ViewConfiguration +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.OverScroller +import androidx.core.animation.doOnEnd +import androidx.core.view.ViewConfigurationCompat +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.widgets.ZoomControl +import io.github.landwarderer.futon.core.util.ext.getAnimationDuration + +private const val MAX_SCALE = 2.5f +private const val MIN_SCALE = 1f + +class DoublePageScalingFrame @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyles: Int = 0, +) : FrameLayout(context, attrs, defStyles), + ScaleGestureDetector.OnScaleGestureListener, + ZoomControl.ZoomControlListener { + + private val scaleDetector = ScaleGestureDetector(context, this) + private val gestureDetector = GestureDetector(context, GestureListener()) + private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator()) + + private val transformMatrix = Matrix() + private val matrixValues = FloatArray(9) + private val scale + get() = matrixValues[Matrix.MSCALE_X] + private val transX + get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X] + private val transY + get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y] + private var halfWidth = 0f + private var halfHeight = 0f + private val translateBounds = RectF() + private var animator: ValueAnimator? = null + + init { + syncMatrixValues() + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + if (ev == null) { + return super.dispatchTouchEvent(ev) + } + + if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) { + overScroller.forceFinished(true) + } + + val consumed = gestureDetector.onTouchEvent(ev) + scaleDetector.onTouchEvent(ev) + + return consumed || scaleDetector.isInProgress || super.dispatchTouchEvent(ev) + } + + override fun onGenericMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { + if (event.actionMasked == MotionEvent.ACTION_SCROLL) { + val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 + if (withCtrl) { + val axisValue = + event.getAxisValue(MotionEvent.AXIS_VSCROLL) * ViewConfigurationCompat.getScaledVerticalScrollFactor( + ViewConfiguration.get(context), context, + ) + val newScale = (scale + axisValue).coerceIn(MIN_SCALE, MAX_SCALE) + scaleChild(newScale, event.x, event.y) + return true + } + } + } + return super.onGenericMotionEvent(event) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + return when (keyCode) { + KeyEvent.KEYCODE_ZOOM_IN, + KeyEvent.KEYCODE_NUMPAD_ADD, + KeyEvent.KEYCODE_PLUS -> { + onZoomIn() + true + } + + KeyEvent.KEYCODE_ZOOM_OUT, + KeyEvent.KEYCODE_NUMPAD_SUBTRACT, + KeyEvent.KEYCODE_MINUS -> { + onZoomOut() + true + } + + KeyEvent.KEYCODE_ESCAPE -> { + smoothScaleTo(1f) + true + } + + else -> super.onKeyDown(keyCode, event) + } + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + return keyCode == KeyEvent.KEYCODE_NUMPAD_ADD + || keyCode == KeyEvent.KEYCODE_PLUS + || keyCode == KeyEvent.KEYCODE_NUMPAD_SUBTRACT + || keyCode == KeyEvent.KEYCODE_MINUS + || keyCode == KeyEvent.KEYCODE_ZOOM_IN + || keyCode == KeyEvent.KEYCODE_ZOOM_OUT + || keyCode == KeyEvent.KEYCODE_ESCAPE + || super.onKeyUp(keyCode, event) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + halfWidth = w / 2f + halfHeight = h / 2f + } + + override fun onZoomIn() { + smoothScaleTo(scale * 1.1f) + } + + override fun onZoomOut() { + smoothScaleTo(scale * 0.9f) + } + + private fun invalidateTarget() { + val targetChild = getChildAt(0) ?: return + adjustBounds() + targetChild.run { + if (!scale.isNaN()) { + scaleX = scale + scaleY = scale + } + translationX = transX + translationY = transY + } + } + + private fun syncMatrixValues() { + transformMatrix.getValues(matrixValues) + } + + private fun adjustBounds() { + syncMatrixValues() + val dx = when { + transX < translateBounds.left -> translateBounds.left - transX + transX > translateBounds.right -> translateBounds.right - transX + else -> 0f + } + + val dy = when { + transY < translateBounds.top -> translateBounds.top - transY + transY > translateBounds.bottom -> translateBounds.bottom - transY + else -> 0f + } + + transformMatrix.postTranslate(dx, dy) + syncMatrixValues() + } + + private fun scaleChild( + newScale: Float, + focusX: Float, + focusY: Float, + ): Boolean { + if (scale.isNaN() || scale == 0f) { + return false + } + val factor = newScale / scale + translateBounds.set( + halfWidth * (1 - newScale), + halfHeight * (1 - newScale), + halfWidth * (newScale - 1), + halfHeight * (newScale - 1), + ) + transformMatrix.postScale(factor, factor, focusX, focusY) + invalidateTarget() + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) + return scaleChild(newScale, detector.focusX, detector.focusY) + } + + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + animator?.cancel() + animator = null + return true + } + + override fun onScaleEnd(p0: ScaleGestureDetector) = Unit + + private fun smoothScaleTo(target: Float) { + val newScale = target.coerceIn(MIN_SCALE, MAX_SCALE) + animator?.cancel() + animator = ValueAnimator.ofFloat(scale, newScale).apply { + setDuration(context.getAnimationDuration(android.R.integer.config_shortAnimTime)) + interpolator = DecelerateInterpolator() + addUpdateListener { scaleChild(it.animatedValue as Float, halfWidth, halfHeight) } + doOnEnd { animator = null } + start() + } + } + + private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable { + private val prevPos = Point() + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + if (scale <= 1f || scale.isNaN()) return false + transformMatrix.postTranslate(-distanceX, -distanceY) + invalidateTarget() + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f + animator?.cancel() + animator = ValueAnimator.ofFloat(scale, newScale).apply { + interpolator = AccelerateDecelerateInterpolator() + duration = context.getAnimationDuration(R.integer.config_defaultAnimTime) + addUpdateListener { + scaleChild(it.animatedValue as Float, e.x, e.y) + } + doOnEnd { animator = null } + start() + } + return true + } + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + if (scale <= 1 || scale.isNaN()) return false + + prevPos.set(transX.toInt(), transY.toInt()) + overScroller.fling( + prevPos.x, + prevPos.y, + velocityX.toInt(), + velocityY.toInt(), + translateBounds.left.toInt(), + translateBounds.right.toInt(), + translateBounds.top.toInt(), + translateBounds.bottom.toInt(), + ) + postOnAnimation(this) + return true + } + + override fun run() { + if (overScroller.computeScrollOffset()) { + transformMatrix.postTranslate( + overScroller.currX.toFloat() - prevPos.x, + overScroller.currY.toFloat() - prevPos.y, + ) + prevPos.set(overScroller.currX, overScroller.currY) + invalidateTarget() + postOnAnimation(this) + } + } + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePagesAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePagesAdapter.kt index 5a501068cc..0fbb6791d5 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePagesAdapter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoublePagesAdapter.kt @@ -18,6 +18,14 @@ class DoublePagesAdapter( exceptionResolver: ExceptionResolver, ) : BaseReaderAdapter(loader, readerSettingsProducer, networkState, exceptionResolver) { + override fun onBindViewHolder(holder: DoublePageHolder, position: Int) { + if (getItem(position).isSpacer) { + holder.bindSpacer() + } else { + super.onBindViewHolder(holder, position) + } + } + override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoubleReaderFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoubleReaderFragment.kt index 474d2eb31b..56c48ef0c3 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoubleReaderFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublepage/DoubleReaderFragment.kt @@ -4,24 +4,30 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator +import androidx.annotation.MainThread import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.yield import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.os.NetworkState import io.github.landwarderer.futon.core.prefs.AppSettings +import io.github.landwarderer.futon.core.prefs.observeAsFlow import io.github.landwarderer.futon.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher import io.github.landwarderer.futon.core.util.ext.firstVisibleItemPosition +import io.github.landwarderer.futon.core.util.ext.observe import io.github.landwarderer.futon.databinding.FragmentReaderDoubleBinding import io.github.landwarderer.futon.reader.domain.PageLoader import io.github.landwarderer.futon.reader.ui.ReaderState -import io.github.landwarderer.futon.reader.ui.pager.BaseReaderAdapter import io.github.landwarderer.futon.reader.ui.pager.BaseReaderFragment import io.github.landwarderer.futon.reader.ui.pager.ReaderPage +import io.github.landwarderer.futon.reader.ui.pager.visibleAdapterPositions import javax.inject.Inject import kotlin.math.absoluteValue @@ -39,6 +45,14 @@ open class DoubleReaderFragment : BaseReaderFragment = emptyList() + private val pagesMutex = Mutex() + + protected open val isReversed: Boolean + get() = false + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -50,6 +64,8 @@ open class DoubleReaderFragment : BaseReaderFragment, pendingState: ReaderState?) = coroutineScope { - val items = launch { - requireAdapter().setItems(pages) - yield() - viewBinding?.recyclerView?.let { rv -> - recyclerLifecycleDispatcher?.invalidate(rv) - } - } - if (pendingState != null) { - var position = pages.indexOfFirst { - it.chapterId == pendingState.chapterId && it.index == pendingState.page + override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = pagesMutex.withLock { + coroutineScope { + sourcePages = pages + val newList = DoublePageList.build( + pages = pages, + coverPage = settings.isReaderDoubleCoverPage, + reversed = isReversed, + ) + val items = launch { + requireAdapter().setItems(newList.items) + pageList = newList + yield() + viewBinding?.recyclerView?.let { rv -> + recyclerLifecycleDispatcher?.invalidate(rv) + } } - items.join() - if (position != -1) { - position = position.toPagePosition() - requireViewBinding().recyclerView.firstVisibleItemPosition = position - notifyPageChanged(position, position + 1) + if (pendingState != null) { + var position = newList.items.indexOfFirst { + it.chapterId == pendingState.chapterId && it.index == pendingState.page + } + items.join() + if (position != -1) { + position = position.toPagePosition() + requireViewBinding().recyclerView.firstVisibleItemPosition = position + notifyPageChanged(position, position + 1) + } else { + Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) + .show() + } } else { - Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) - .show() + items.join() } - } else { - items.join() } } @@ -100,18 +133,16 @@ open class DoubleReaderFragment : BaseReaderFragment 1 || !isAnimationEnabled()) { - switchPageTo(getCurrentItem() + delta + delta, false) + scrollToAdapterPosition(getCurrentItem() + delta + delta) return } val rv = viewBinding?.recyclerView ?: return @@ -120,14 +151,14 @@ open class DoubleReaderFragment : BaseReaderFragment= 0) { + scrollToAdapterPosition(adapterPosition) + } } override fun getCurrentState(): ReaderState? = viewBinding?.run { - val adapter = recyclerView.adapter as? BaseReaderAdapter<*> - val page = adapter?.getItemOrNull(getCurrentItem()) ?: return@run null + val page = pageList.getSpreadPage(getCurrentItem()) ?: return@run null ReaderState( chapterId = page.chapterId, page = page.index, @@ -135,8 +166,22 @@ open class DoubleReaderFragment : BaseReaderFragment adapter.getItemOrNull(pos)?.takeUnless { it.isSpacer } } + viewModel.onCurrentPageChanged(pages.firstOrNull(), pages.lastOrNull()) + } + + private fun scrollToAdapterPosition(position: Int) { + val lm = viewBinding?.recyclerView?.layoutManager as? LinearLayoutManager ?: return + lm.scrollToPositionWithOffset(position.toPagePosition(), 0) } private fun getCurrentItem() = (requireViewBinding().recyclerView.layoutManager as LinearLayoutManager) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublereversed/ReversedDoubleReaderFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublereversed/ReversedDoubleReaderFragment.kt index 3686ad0546..cbe6e97feb 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublereversed/ReversedDoubleReaderFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/ui/pager/doublereversed/ReversedDoubleReaderFragment.kt @@ -1,24 +1,13 @@ package io.github.landwarderer.futon.reader.ui.pager.doublereversed -import io.github.landwarderer.futon.reader.ui.ReaderState -import io.github.landwarderer.futon.reader.ui.pager.ReaderPage import io.github.landwarderer.futon.reader.ui.pager.doublepage.DoubleReaderFragment class ReversedDoubleReaderFragment : DoubleReaderFragment() { + override val isReversed: Boolean + get() = true + override fun switchPageBy(delta: Int) { super.switchPageBy(-delta) } - - override fun switchPageTo(position: Int, smooth: Boolean) { - super.switchPageTo(reversed(position), smooth) - } - - override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) { - super.onPagesChanged(pages.reversed(), pendingState) - } - - private fun reversed(position: Int): Int { - return ((readerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) - } } 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/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/domain/MangaSearchRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt index 4f92da9afd..5ff1085f7e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/MangaSearchRepository.kt @@ -9,15 +9,23 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.db.MangaDatabase +import io.github.landwarderer.futon.core.db.SourceSearchFilter +import io.github.landwarderer.futon.core.db.likeContainsPattern import io.github.landwarderer.futon.core.db.entity.toEntity import io.github.landwarderer.futon.core.db.entity.toManga import io.github.landwarderer.futon.core.db.entity.toMangaTag import io.github.landwarderer.futon.core.db.entity.toMangaTagsList +import io.github.landwarderer.futon.core.model.FavouriteCategory +import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.novel.data.source.NovelParserSource +import io.github.landwarderer.futon.favourites.data.toFavouriteCategory +import io.github.landwarderer.futon.mihon.MihonExtensionManager +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionScope import io.github.landwarderer.futon.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga @@ -27,6 +35,10 @@ import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.mapToSet import io.github.landwarderer.futon.search.ui.MangaSuggestionsProvider import javax.inject.Inject +import io.github.landwarderer.futon.core.model.MangaSource as mangaSourceOf + +/** How many of a screen's sources to consider when suggesting names to filter by. */ +private const val SOURCE_SCAN_LIMIT = 64 @Reusable class MangaSearchRepository @Inject constructor( @@ -35,8 +47,92 @@ class MangaSearchRepository @Inject constructor( @ApplicationContext private val context: Context, private val recentSuggestions: SearchRecentSuggestions, private val settings: AppSettings, + private val sourceSearchFilter: SourceSearchFilter, + private val mihonExtensionManager: MihonExtensionManager, ) { + /** + * Sources present on the current screen whose name matches what is being typed, so the user can + * filter by source without knowing its exact spelling. Covers built-in parsers and Mihon sources. + */ + suspend fun getScopedSources( + query: String, + scope: SearchSuggestionScope, + limit: Int, + ): List { + val names = when (scope) { + SearchSuggestionScope.HISTORY -> db.getHistoryDao().findPopularSources(SOURCE_SCAN_LIMIT) + SearchSuggestionScope.FAVOURITES -> db.getFavouritesDao().findPopularSources(SOURCE_SCAN_LIMIT) + SearchSuggestionScope.ALL -> return emptyList() + } + return names.asSequence() + .map { name -> mihonExtensionManager.getMihonMangaSourceByName(name) ?: mangaSourceOf(name) } + .filter { query.isEmpty() || it.getTitle(context).contains(query, ignoreCase = true) } + .take(limit) + .toList() + } + + /** + * `true` when the typed text is the word for favourites itself, rather than the name of one list. + */ + fun matchesFavouritesKeyword(query: String): Boolean { + if (query.length < 3) { + return false + } + return context.getString(R.string.favourites).contains(query, ignoreCase = true) + } + + suspend fun getAllCategories(): List = db.getFavouriteCategoriesDao() + .findAll() + .map { it.toFavouriteCategory() } + + /** + * Favourite lists whose name matches what is being typed. Offered on History too, so "show me what + * I read out of this list" works from either screen. + */ + suspend fun getScopedCategories( + query: String, + scope: SearchSuggestionScope, + limit: Int, + ): List { + if (scope == SearchSuggestionScope.ALL) { + return emptyList() + } + return db.getFavouriteCategoriesDao().findAll() + .asSequence() + .map { it.toFavouriteCategory() } + .filter { query.isEmpty() || it.title.contains(query, ignoreCase = true) } + .take(limit) + .toList() + } + + /** + * Manga suggestions restricted to one screen's contents, matching title, source or list name. + * [SearchSuggestionScope.ALL] falls through to the library-wide lookup. + */ + suspend fun getMangaSuggestion( + query: String, + limit: Int, + source: MangaSource?, + scope: SearchSuggestionScope, + ): List { + if (scope == SearchSuggestionScope.ALL || query.isEmpty()) { + return getMangaSuggestion(query, limit, source) + } + val pattern = likeContainsPattern(query) + val sourceNames = sourceSearchFilter.findSourceNames(query) + val entities = when (scope) { + SearchSuggestionScope.HISTORY -> db.getHistoryDao().filter(pattern, sourceNames, limit) + SearchSuggestionScope.FAVOURITES -> db.getFavouritesDao().filter(pattern, sourceNames, limit) + SearchSuggestionScope.ALL -> return getMangaSuggestion(query, limit, source) + } + return entities.asSequence() + .filterNot { settings.isNsfwContentDisabled && it.manga.isNsfw } + .map { it.toManga() } + .sortedBy { it.title.levenshteinDistance(query) } + .toList() + } + suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List = when { query.isEmpty() -> db.getSuggestionDao().getTopManga(limit) source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/ScreenSearchQuery.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/ScreenSearchQuery.kt new file mode 100644 index 0000000000..c7476ae84d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/domain/ScreenSearchQuery.kt @@ -0,0 +1,49 @@ +package io.github.landwarderer.futon.search.domain + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionScope +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The text currently filtering a library screen, held per screen. + * + * Shared rather than passed down because the search bar lives in MainActivity while the list it + * filters lives in a fragment - and for Favourites, inside a pager whose pages come and go. + * + * Kept per [SearchSuggestionScope] rather than as one string: History and Favourites both observe + * this, so a single value would have one screen's filter silently applied to the other whenever both + * view models happen to be alive at once. + */ +@Singleton +class ScreenSearchQuery @Inject constructor() { + + private val queries = ConcurrentHashMap>() + private val activeScope = MutableStateFlow(SearchSuggestionScope.ALL) + + /** What the screen showing [scope] should be filtered by. Empty means no filter. */ + fun query(scope: SearchSuggestionScope): StateFlow = flowFor(scope) + + /** The filter for whichever screen the search bar currently belongs to. */ + @OptIn(ExperimentalCoroutinesApi::class) + val activeQuery: Flow = activeScope.flatMapLatest { flowFor(it) } + + fun setActiveScope(scope: SearchSuggestionScope) { + activeScope.value = scope + } + + fun set(scope: SearchSuggestionScope, value: String) { + flowFor(scope).value = value.trim() + } + + fun clear(scope: SearchSuggestionScope) { + flowFor(scope).value = "" + } + + private fun flowFor(scope: SearchSuggestionScope) = queries.getOrPut(scope) { MutableStateFlow("") } +} 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..87ad4a4f1c 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 @@ -20,6 +20,7 @@ import io.github.landwarderer.futon.core.util.ext.append import io.github.landwarderer.futon.core.util.ext.printStackTraceDebug import io.github.landwarderer.futon.core.util.ext.toLocale import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository import io.github.landwarderer.futon.favourites.domain.FavouritesRepository import io.github.landwarderer.futon.history.data.HistoryRepository import io.github.landwarderer.futon.list.domain.MangaListMapper @@ -63,6 +64,7 @@ class SearchViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, private val dataRepository: MangaDataRepository, + private val presetsRepository: SourcePresetsRepository, ) : BaseViewModel() { val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() @@ -89,7 +91,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 +105,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() @@ -152,8 +154,10 @@ class SearchViewModel @Inject constructor( val sources = if (pinnedOnly.value) { emptyList() } else { + val presetSourceNames = getActivePresetSources()?.mapTo(HashSet()) { it.name }.orEmpty() sourcesRepository.getDisabledSources() .filter { it.isNovelSource == isNovel } + .filterNot { it.name in presetSourceNames } .sortedByDescending { it.priority() } } val semaphore = Semaphore(MAX_PARALLELISM) @@ -175,7 +179,7 @@ class SearchViewModel @Inject constructor( appendResult(searchHistory()) appendResult(searchFavorites()) appendResult(searchLocal()) - val sources = if (pinnedOnly.value) { + val sources = getActivePresetSources()?.filter { it.isNovelSource == isNovel } ?: if (pinnedOnly.value) { sourcesRepository.getPinnedSources().filter { it.isNovelSource == isNovel } } else { sourcesRepository.getEnabledSources().filter { it.isNovelSource == isNovel } @@ -193,6 +197,14 @@ class SearchViewModel @Inject constructor( // impl + /** The sources of the active preset, or null to fall back to the enabled (or pinned) ones. */ + private suspend fun getActivePresetSources(): List? { + val preset = presetsRepository.getById(settings.activeSourcePresetId) ?: return null + return sourcesRepository.getPresetSources(preset).let { sources -> + if (pinnedOnly.value) sources.filter { it.isPinned } else sources + } + } + private suspend fun searchSource(source: MangaSource): SearchResultsListModel? = runCatchingCancellable { val searchHelper = searchHelperFactory.create(source) searchHelper(query, kind) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListener.kt index 403dd51522..f36544775d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListener.kt @@ -2,6 +2,7 @@ package io.github.landwarderer.futon.search.ui.suggestion import android.text.TextWatcher import android.widget.TextView +import io.github.landwarderer.futon.core.model.FavouriteCategory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag @@ -18,4 +19,11 @@ interface SearchSuggestionListener : TextWatcher, TextView.OnEditorActionListene fun onSourceClick(source: MangaSource) fun onTagClick(tag: MangaTag) + + fun onScopeChanged(isScoped: Boolean) + + fun onFavouriteCategoryClick(category: FavouriteCategory) + + /** Expands the single "favourites" entry into every list. */ + fun onFavouritesGroupClick() } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListenerImpl.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListenerImpl.kt index d7fcf3e7e3..3c3aacee84 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListenerImpl.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionListenerImpl.kt @@ -5,6 +5,8 @@ import android.view.KeyEvent import android.widget.TextView import androidx.core.net.toUri import com.google.android.material.search.SearchView +import io.github.landwarderer.futon.core.model.FavouriteCategory +import io.github.landwarderer.futon.core.model.getTitle import io.github.landwarderer.futon.core.nav.AppRouter import io.github.landwarderer.futon.core.parser.MangaLinkResolver import org.koitharu.kotatsu.parsers.model.Manga @@ -18,11 +20,26 @@ class SearchSuggestionListenerImpl( private val viewModel: SearchSuggestionViewModel, ) : SearchSuggestionListener { + override fun onScopeChanged(isScoped: Boolean) { + viewModel.setScoped(isScoped) + } + override fun onMangaClick(manga: Manga) { router.openDetails(manga) } override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) { + if (viewModel.isScopedSearch.value) { + // Filtering a screen, not starting a search: apply the text to the list underneath and step + // out of the way instead of navigating anywhere. The bar keeps the text so it stays obvious + // that the list is filtered. + searchView.setText(query) + if (submit) { + viewModel.applyScreenFilter(query) + searchView.hide() + } + return + } if (submit && query.isNotEmpty()) { if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) { router.openDetails(query.toUri()) @@ -39,6 +56,10 @@ class SearchSuggestionListenerImpl( } override fun onTagClick(tag: MangaTag) { + if (viewModel.isScopedSearch.value) { + searchView.setText(tag.title) + return + } router.openSearch(tag.title, SearchKind.TAG) } @@ -47,9 +68,28 @@ class SearchSuggestionListenerImpl( } override fun onSourceClick(source: MangaSource) { + if (viewModel.isScopedSearch.value) { + applyFilter(source.getTitle(searchView.context)) + return + } router.openList(source, null, null) } + override fun onFavouritesGroupClick() { + viewModel.expandFavouritesGroup() + } + + override fun onFavouriteCategoryClick(category: FavouriteCategory) { + applyFilter(category.title) + } + + /** Narrows the screen behind the overlay and steps out of the way. */ + private fun applyFilter(text: String) { + searchView.setText(text) + viewModel.applyScreenFilter(text) + searchView.hide() + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionScope.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionScope.kt new file mode 100644 index 0000000000..58726f94d6 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionScope.kt @@ -0,0 +1,16 @@ +package io.github.landwarderer.futon.search.ui.suggestion + +/** + * What the shared search bar should look through. + * + * The bar is the same on every screen, but on History and Favourites it narrows to that screen's + * contents instead of offering library-wide suggestions, so typing a title, a source or a list name + * filters what you were already looking at. + */ +enum class SearchSuggestionScope { + + /** Library-wide suggestions: the behaviour everywhere else. */ + ALL, + HISTORY, + FAVOURITES, +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt index 9f13a8081a..eefbc9419e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -4,9 +4,23 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.landwarderer.futon.core.model.isNovel import io.github.landwarderer.futon.core.model.isNovelSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.plus import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.SearchSuggestionType import io.github.landwarderer.futon.core.prefs.observeAsFlow +import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.prefs.observeAsStateFlow import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.ui.widgets.ChipsView @@ -17,19 +31,8 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import io.github.landwarderer.futon.search.domain.MangaSearchRepository +import io.github.landwarderer.futon.search.domain.ScreenSearchQuery import io.github.landwarderer.futon.search.ui.suggestion.model.SearchSuggestionItem -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.plus import javax.inject.Inject private const val DEBOUNCE_TIMEOUT = 300L @@ -40,17 +43,40 @@ private const val MAX_AUTHORS_ITEMS = 2 private const val MAX_TAGS_ITEMS = 8 private const val MAX_SOURCES_ITEMS = 6 private const val MAX_SOURCES_TIPS_ITEMS = 2 +private const val MAX_SCOPE_TIPS_ITEMS = 12 @HiltViewModel class SearchSuggestionViewModel @Inject constructor( private val repository: MangaSearchRepository, private val settings: AppSettings, private val sourcesRepository: MangaSourcesRepository, + private val screenSearchQuery: ScreenSearchQuery, ) : BaseViewModel() { private val query = MutableStateFlow("") private val invalidationTrigger = MutableStateFlow(0) + /** + * Which screen the search bar currently belongs to, set by MainActivity as the section changes. + * Only decides whether narrowing is *offered*, never whether it is applied. + */ + private val screenScope = MutableStateFlow(SearchSuggestionScope.ALL) + + /** + * Whether the user has actually asked to narrow to the current screen. + * + * Defaults to `false` and resets whenever the section changes, so search behaves exactly as it + * always has until someone taps the chip - existing habits are never silently changed. + */ + private val isScoped = MutableStateFlow(false) + + /** Set by tapping the "favourites" entry; shows every list until the query changes. */ + private val areListsExpanded = MutableStateFlow(false) + + /** Whether the bar is currently filtering one screen rather than searching everywhere. */ + val isScopedSearch: StateFlow + get() = isScoped + val isIncognitoModeEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.IO, key = AppSettings.KEY_INCOGNITO_MODE, @@ -63,17 +89,61 @@ class SearchSuggestionViewModel @Inject constructor( settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes }, settings.observeAppMode(), invalidationTrigger, - ) - { a, b, c, _, _ -> - Triple(a, b, c) - }.mapLatest { (searchQuery, enabledSources, types) -> - buildSearchSuggestion(searchQuery, enabledSources, types) + screenScope, + isScoped, + areListsExpanded, + ) { values -> + @Suppress("UNCHECKED_CAST") + SuggestionInput( + query = values[0] as String, + enabledSources = values[1] as Set, + types = values[2] as Set, + screenScope = values[5] as SearchSuggestionScope, + isScoped = values[6] as Boolean, + areListsExpanded = values[7] as Boolean, + ) + }.mapLatest { input -> + buildSearchSuggestion(input) }.distinctUntilChanged() .withErrorHandling() .flowOn(Dispatchers.IO) fun onQueryChanged(newQuery: String) { query.value = newQuery + // Typing again is a new intent; collapse back to the single entry. + areListsExpanded.value = false + if (isScoped.value && newQuery.isEmpty()) { + // In filter mode an empty bar means an unfiltered screen. Without this the filter is only + // ever written on submit, so clearing the text leaves the old one applied and it reappears + // in the bar the next time this screen is shown. + screenSearchQuery.clear(screenScope.value) + } + } + + fun expandFavouritesGroup() { + areListsExpanded.value = true + } + + fun setScope(value: SearchSuggestionScope) { + if (screenScope.value != value) { + // Leaving a screen drops its narrowing, and its filter, with it. + screenSearchQuery.clear(screenScope.value) + screenScope.value = value + isScoped.value = false + screenSearchQuery.setActiveScope(value) + } + } + + fun setScoped(value: Boolean) { + isScoped.value = value + if (!value) { + // Back to searching everywhere: the screen should stop being filtered. + screenSearchQuery.clear(screenScope.value) + } + } + + fun applyScreenFilter(value: String) { + screenSearchQuery.set(screenScope.value, value) } fun saveQuery(query: String) { @@ -104,18 +174,62 @@ class SearchSuggestionViewModel @Inject constructor( } private suspend fun buildSearchSuggestion( - searchQuery: String, - enabledSources: Set, - types: Set, + input: SuggestionInput, ): List = coroutineScope { + val searchQuery = input.query + val enabledSources = input.enabledSources + val types = input.types + val scope = input.appliedScope + if (input.isScoped) { + // Pure filter mode: just the switch and the matching titles. The other sections all lead + // somewhere else - a source, a tag search, a saved query - and offering them here would + // defeat the point of staying on the screen you are filtering. + // With nothing typed yet, offer everything worth narrowing to - every list, and the sources + // actually present on this screen - so the mode opens with choices rather than a blank sheet. + val tipLimit = if (searchQuery.isEmpty()) MAX_SCOPE_TIPS_ITEMS else MAX_SOURCES_TIPS_ITEMS + // Typing the word "favourites" offers one entry rather than every list at once; tapping it + // expands. Collapsed, it previews the names so it is clear what it opens. + val isFavouritesKeyword = searchQuery.isNotEmpty() && repository.matchesFavouritesKeyword(searchQuery) + return@coroutineScope buildList { + add(SearchSuggestionItem.Scope(scopeChips(input))) + if (isFavouritesKeyword && !input.areListsExpanded) { + val all = repository.getAllCategories() + if (all.isNotEmpty()) { + add(SearchSuggestionItem.FavouritesGroup(all.joinToString { it.title })) + } + } else if (isFavouritesKeyword) { + repository.getAllCategories().mapTo(this) { SearchSuggestionItem.FavouriteTip(it) } + } else { + repository.getScopedCategories(searchQuery, scope, tipLimit).mapTo(this) { + SearchSuggestionItem.FavouriteTip(it) + } + } + repository.getScopedSources(searchQuery, scope, tipLimit).mapTo(this) { + SearchSuggestionItem.SourceTip(it) + } + if (searchQuery.isNotEmpty()) { + // An empty query would otherwise fall through to library-wide "top manga", which is + // not this screen's content. + addAll(getManga(searchQuery, scope)) + } + } + } listOfNotNull( - if (SearchSuggestionType.GENRES in types) { + if (input.screenScope != SearchSuggestionScope.ALL) { + async { listOf(SearchSuggestionItem.Scope(scopeChips(input))) } + } else { + null + }, + // Tags are a suggestion for what has been typed. With an empty field getTagsSuggestion falls + // back to arbitrary popular tags, which renders a strip of filter chips under the scope + // chips before the user has asked for anything - so they only appear once there is a query. + if (SearchSuggestionType.GENRES in types && searchQuery.isNotEmpty()) { async { getTags(searchQuery) } } else { null }, if (SearchSuggestionType.MANGA in types) { - async { getManga(searchQuery) } + async { getManga(searchQuery, scope) } } else { null }, @@ -185,9 +299,42 @@ class SearchSuggestionViewModel @Inject constructor( listOf(SearchSuggestionItem.Text(0, e)) } - private suspend fun getManga(searchQuery: String): List = runCatchingCancellable { + private fun scopeChips(input: SuggestionInput): List = listOf( + ChipsView.ChipModel( + titleResId = R.string.search, + isChecked = !input.isScoped, + data = false, + ), + ChipsView.ChipModel( + titleResId = when (input.screenScope) { + SearchSuggestionScope.FAVOURITES -> R.string.favourites + else -> R.string.history + }, + isChecked = input.isScoped, + data = true, + ), + ) + + private data class SuggestionInput( + val query: String, + val enabledSources: Set, + val types: Set, + val screenScope: SearchSuggestionScope, + val isScoped: Boolean, + val areListsExpanded: Boolean, + ) { + + /** The scope actually used for lookups: narrowing only applies once the user opts in. */ + val appliedScope: SearchSuggestionScope + get() = if (isScoped) screenScope else SearchSuggestionScope.ALL + } + + private suspend fun getManga( + searchQuery: String, + scope: SearchSuggestionScope, + ): List = runCatchingCancellable { val isNovel = settings.appMode.isNovel - val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) + val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null, scope) .filter { it.isNovel == isNovel } if (manga.isEmpty()) { emptyList() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt index cf0911689e..de3ea4c56b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt @@ -15,6 +15,9 @@ class SearchSuggestionAdapter( .addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener)) .addDelegate(searchSuggestionSourceAD(listener)) .addDelegate(searchSuggestionSourceTipAD(listener)) + .addDelegate(searchSuggestionFavouriteTipAD(listener)) + .addDelegate(searchSuggestionFavouritesGroupAD(listener)) + .addDelegate(searchSuggestionScopeAD(listener)) .addDelegate(searchSuggestionTagsAD(listener)) .addDelegate(searchSuggestionMangaListAD(listener)) .addDelegate(searchSuggestionQueryHintAD(listener)) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouriteTipAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouriteTipAD.kt new file mode 100644 index 0000000000..53dd559780 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouriteTipAD.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.search.ui.suggestion.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.databinding.ItemSearchSuggestionSourceTipBinding +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionListener +import io.github.landwarderer.futon.search.ui.suggestion.model.SearchSuggestionItem + +/** + * Reuses the source tip row so a list and a source look like the same kind of thing to filter by - + * icon, name, subtitle - with a heart standing in for the favicon. + */ +fun searchSuggestionFavouriteTipAD( + listener: SearchSuggestionListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemSearchSuggestionSourceTipBinding.inflate(inflater, parent, false) }, +) { + + binding.root.setOnClickListener { + listener.onFavouriteCategoryClick(item.category) + } + + bind { + binding.textViewTitle.text = item.category.title + binding.textViewSubtitle.setText(R.string.favourites) + binding.imageViewCover.setImageAsync(R.drawable.ic_heart_outline) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouritesGroupAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouritesGroupAD.kt new file mode 100644 index 0000000000..22d312cde0 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionFavouritesGroupAD.kt @@ -0,0 +1,28 @@ +package io.github.landwarderer.futon.search.ui.suggestion.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.databinding.ItemSearchSuggestionSourceTipBinding +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionListener +import io.github.landwarderer.futon.search.ui.suggestion.model.SearchSuggestionItem + +/** + * "Favourites" as one entry. The subtitle previews the list names so it is obvious what tapping it + * will open, without needing a count nobody can interpret. + */ +fun searchSuggestionFavouritesGroupAD( + listener: SearchSuggestionListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemSearchSuggestionSourceTipBinding.inflate(inflater, parent, false) }, +) { + + binding.root.setOnClickListener { + listener.onFavouritesGroupClick() + } + + bind { + binding.textViewTitle.setText(R.string.favourites) + binding.textViewSubtitle.text = item.preview + binding.imageViewCover.setImageAsync(R.drawable.ic_heart_outline) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionScopeAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionScopeAD.kt new file mode 100644 index 0000000000..dceb76da6d --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/adapter/SearchSuggestionScopeAD.kt @@ -0,0 +1,26 @@ +package io.github.landwarderer.futon.search.ui.suggestion.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import io.github.landwarderer.futon.core.ui.widgets.ChipsView +import io.github.landwarderer.futon.databinding.ItemSearchSuggestionTagsBinding +import io.github.landwarderer.futon.search.ui.suggestion.SearchSuggestionListener +import io.github.landwarderer.futon.search.ui.suggestion.model.SearchSuggestionItem + +/** + * Reuses the tags row layout: both are a single horizontal strip of chips, and giving the scope + * switch its own look would make it read as unrelated to the rest of the suggestions. + */ +fun searchSuggestionScopeAD( + listener: SearchSuggestionListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSearchSuggestionTagsBinding.inflate(layoutInflater, parent, false) }, +) { + + binding.chipsGenres.onChipClickListener = ChipsView.OnChipClickListener { _, data -> + listener.onScopeChanged(data as? Boolean ?: return@OnChipClickListener) + } + + bind { + binding.chipsGenres.setChips(item.chips) + } +} diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/model/SearchSuggestionItem.kt index e3fd777ccb..2e1e205374 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/model/SearchSuggestionItem.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/search/ui/suggestion/model/SearchSuggestionItem.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.search.ui.suggestion.model import androidx.annotation.StringRes +import io.github.landwarderer.futon.core.model.FavouriteCategory import io.github.landwarderer.futon.core.model.isNsfw import io.github.landwarderer.futon.core.ui.widgets.ChipsView import io.github.landwarderer.futon.list.ui.ListModelDiffCallback @@ -82,6 +83,49 @@ sealed interface SearchSuggestionItem : ListModel { } } + /** + * Stands for "favourites" as a whole rather than one list: typing the word offers this single entry, + * and tapping it opens up every list instead of making the user guess their names. + */ + data class FavouritesGroup( + val preview: String, + ) : SearchSuggestionItem { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is FavouritesGroup + } + } + + /** + * A favourites list offered as something to filter by, shown alongside sources so both read as the + * same kind of choice. + */ + data class FavouriteTip( + val category: FavouriteCategory, + ) : SearchSuggestionItem { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is FavouriteTip && other.category.id == category.id + } + } + + /** + * Chips that choose what the shared search bar looks through. Only emitted on screens where + * narrowing is meaningful, and "everywhere" is always the selected default. + */ + data class Scope( + val chips: List, + ) : SearchSuggestionItem { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is Scope + } + + override fun getChangePayload(previousState: ListModel): Any { + return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED + } + } + data class Tags( val tags: List, ) : SearchSuggestionItem { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt index d110d1feb2..ecd04b46b0 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/AppearanceSettingsFragment.kt @@ -100,8 +100,9 @@ class AppearanceSettingsFragment : findPreference(AppSettings.KEY_MANGA_LIST_BADGES)?.run { summaryProvider = MultiSummaryProvider(R.string.none) } - findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = - appShortcutManager.isDynamicShortcutsAvailable() + val isShortcutsAvailable = appShortcutManager.isDynamicShortcutsAvailable() + findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = isShortcutsAvailable + findPreference(AppSettings.KEY_SHORTCUTS_NO_NSFW)?.isVisible = isShortcutsAvailable findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() findPreference(AppSettings.KEY_SCREENSHOTS_POLICY)?.run { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt index 44ccf27251..c9e270cd2b 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/ReaderSettingsFragment.kt @@ -16,6 +16,7 @@ import io.github.landwarderer.futon.core.model.ZoomMode import io.github.landwarderer.futon.core.nav.router import io.github.landwarderer.futon.core.prefs.AppSettings import io.github.landwarderer.futon.core.prefs.ChapterCompletionMode +import io.github.landwarderer.futon.core.prefs.EInkFlashColor import io.github.landwarderer.futon.settings.reader.SmartResumeThresholdDialog import io.github.landwarderer.futon.settings.utils.HelpSwitchPreference import io.github.landwarderer.futon.core.prefs.ReaderAnimation @@ -23,6 +24,7 @@ import io.github.landwarderer.futon.core.prefs.ReaderBackground import io.github.landwarderer.futon.core.prefs.ReaderControl import io.github.landwarderer.futon.core.prefs.ReaderMode import io.github.landwarderer.futon.core.ui.BasePreferenceFragment +import io.github.landwarderer.futon.core.util.ext.getQuantityStringSafe import io.github.landwarderer.futon.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names @@ -71,6 +73,18 @@ class ReaderSettingsFragment : summaryProvider = MultiSummaryProvider(R.string.disabled) } findPreference(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider() + findPreference(AppSettings.KEY_EINK_FLASH_DURATION)?.summaryProvider = + FlashDurationSummaryProvider + findPreference(AppSettings.KEY_EINK_FLASH_EVERY)?.summaryProvider = + FlashEverySummaryProvider + findPreference(AppSettings.KEY_EINK_FLASH_COLOR)?.run { + entries = arrayOf( + getString(R.string.color_white), + getString(R.string.color_black), + ) + entryValues = EInkFlashColor.entries.names() + setDefaultValueCompat(EInkFlashColor.WHITE.name) + } updateReaderModeDependency() findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)?.summaryProvider = Preference.SummaryProvider { @@ -149,4 +163,19 @@ class ReaderSettingsFragment : findPreference(AppSettings.KEY_SMART_RESUME_PERCENTAGE)?.isVisible = percentage findPreference(AppSettings.KEY_SMART_RESUME_PAGES)?.isVisible = !percentage } + + private object FlashDurationSummaryProvider : Preference.SummaryProvider { + + override fun provideSummary(preference: SliderPreference): CharSequence { + return preference.context.getString(R.string.milliseconds_pattern, preference.value) + } + } + + private object FlashEverySummaryProvider : Preference.SummaryProvider { + + override fun provideSummary(preference: SliderPreference): CharSequence { + val value = preference.value + return preference.context.resources.getQuantityStringSafe(R.plurals.pages, value, value) + } + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigActivity.kt index 33c93f290f..0abafe3a8d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigActivity.kt @@ -13,6 +13,7 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.alternatives.ui.covers.AlternativeCoversActivity import io.github.landwarderer.futon.core.ui.BaseActivity import io.github.landwarderer.futon.core.ui.model.MangaOverride import io.github.landwarderer.futon.core.util.ext.consumeAll @@ -34,6 +35,11 @@ class OverrideConfigActivity : BaseActivity(), View private val pickCoverFileLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument(), this) private val pickPageLauncher = registerForActivityResult(PageImagePickContract(), this) + private val pickAlternativeCoverLauncher = registerForActivityResult(AlternativeCoversActivity.Contract()) { cover -> + if (cover != null) { + viewModel.updateCover(cover.url, cover.source) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -42,6 +48,7 @@ class OverrideConfigActivity : BaseActivity(), View viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonPickFile.setOnClickListener(this) viewBinding.buttonPickPage.setOnClickListener(this) + viewBinding.buttonPickAlternative.setOnClickListener(this) viewBinding.buttonResetCover.setOnClickListener(this) viewBinding.layoutName.setEndIconOnClickListener(this) viewModel.data.filterNotNull().observe(this, ::onDataChanged) @@ -94,6 +101,10 @@ class OverrideConfigActivity : BaseActivity(), View val manga = viewModel.data.value?.first pickPageLauncher.launch(manga) } + + R.id.button_pick_alternative -> { + viewModel.data.value?.first?.let(pickAlternativeCoverLauncher::launch) + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigViewModel.kt index a63e8d8c47..3d341b89c6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/override/OverrideConfigViewModel.kt @@ -8,10 +8,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request import okio.buffer import okio.sink import io.github.landwarderer.futon.core.model.parcelable.ParcelableManga import io.github.landwarderer.futon.core.nav.AppRouter +import io.github.landwarderer.futon.core.model.unwrap +import io.github.landwarderer.futon.core.network.MangaHttpClient import io.github.landwarderer.futon.core.parser.MangaDataRepository import io.github.landwarderer.futon.core.ui.BaseViewModel import io.github.landwarderer.futon.core.ui.model.MangaOverride @@ -19,13 +23,17 @@ import io.github.landwarderer.futon.core.util.MimeTypes 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.isFileUri +import io.github.landwarderer.futon.core.util.ext.isHttpUrl import io.github.landwarderer.futon.core.util.ext.openSource import io.github.landwarderer.futon.core.util.ext.require import io.github.landwarderer.futon.core.util.ext.toMimeTypeOrNull import io.github.landwarderer.futon.core.util.ext.toUriOrNull import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.md5 import java.io.File +import java.io.IOException import javax.inject.Inject private const val DIR_COVERS = "covers" @@ -35,6 +43,7 @@ class OverrideConfigViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @ApplicationContext private val context: Context, private val dataRepository: MangaDataRepository, + @MangaHttpClient private val httpClient: OkHttpClient, ) : BaseViewModel() { private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga @@ -42,6 +51,10 @@ class OverrideConfigViewModel @Inject constructor( val data = MutableStateFlow?>(null) val onSaved = MutableEventFlow() + /** Source a remote cover was picked from, so it is downloaded with that source's headers. */ + @Volatile + private var remoteCoverSource: MangaSource? = null + init { launchLoadingJob(Dispatchers.IO) { data.value = manga to (dataRepository.getOverride(manga.id) ?: emptyOverride()) @@ -61,8 +74,9 @@ class OverrideConfigViewModel @Inject constructor( } } - fun updateCover(coverUri: String?) { + fun updateCover(coverUri: String?, source: MangaSource? = null) { val snapshot = data.value ?: return + remoteCoverSource = source data.value = snapshot.first to snapshot.second.copy( coverUrl = coverUri, ) @@ -74,6 +88,12 @@ class OverrideConfigViewModel @Inject constructor( return this } val cacheDir = context.getExternalFilesDir(DIR_COVERS) ?: return this + // A cover chosen from another source is a remote url, which the ContentResolver cannot open. + // Download it instead, so the override keeps working even if that site later blocks hotlinking + // or moves the file. + if (isHttpUrl()) { + return downloadCover(cacheDir) + } val cr = context.contentResolver val ext = cr.getType(uri)?.toMimeTypeOrNull()?.let { MimeTypes.getExtension(it) @@ -96,5 +116,44 @@ class OverrideConfigViewModel @Inject constructor( }.toUri().toString() } + private suspend fun String.downloadCover(cacheDir: File): String = withContext(Dispatchers.IO) { + val url = this@downloadCover + val request = Request.Builder() + .url(url) + .get() + .apply { + // CommonHeadersInterceptor adds the source's referer and user agent from this tag. + remoteCoverSource?.unwrap()?.let { tag(MangaSource::class.java, it) } + } + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Cannot download cover: HTTP ${response.code}") + } + val ext = response.body.contentType()?.toString()?.substringBefore(';') + ?.toMimeTypeOrNull() + ?.let { MimeTypes.getExtension(it) } + .ifNullOrEmpty { + // Some CDNs answer with a generic content type; the url usually still names the format. + url.substringBefore('?').substringAfterLast('.', "") + .takeIf { it.length in 1..4 } + } + val dest = File( + cacheDir, + buildString { + append(url.md5()) + if (!ext.isNullOrEmpty()) { + append('.') + append(ext) + } + }, + ) + dest.sink().buffer().use { sink -> + sink.writeAll(response.body.source()) + } + dest.toUri().toString() + } + } + private fun emptyOverride() = MangaOverride(null, null, null) } 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/SourceCatalogItem.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt index 83c0ea02ab..dcf125d03c 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItem.kt @@ -9,6 +9,7 @@ sealed interface SourceCatalogItem : ListModel { data class Source( val source: MangaSource, + val isInPreset: Boolean = false, ) : SourceCatalogItem { override fun areItemsTheSame(other: ListModel): Boolean { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt index e52a36f090..5bec251630 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourceCatalogItemAD.kt @@ -50,6 +50,7 @@ fun sourceCatalogItemSourceAD( } FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) binding.imageViewIcon.setImageAsync(item.source) + binding.imageViewAdd.setImageResource(if (item.isInPreset) R.drawable.ic_check else R.drawable.ic_add) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogActivity.kt index 8e5ae2ddd9..041e026d2e 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/catalog/SourcesCatalogActivity.kt @@ -67,6 +67,9 @@ class SourcesCatalogActivity : BaseActivity(), updateFilers(it.first, it.second, it.third) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) + viewModel.activePreset.observe(this) { preset -> + supportActionBar?.subtitle = preset?.title + } } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { @@ -95,11 +98,19 @@ class SourcesCatalogActivity : BaseActivity(), } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { - router.openList(item.source, null, null, item.source.getTitle(this)) + if (viewModel.isPresetMode) { + viewModel.togglePresetSource(item.source) + } else { + router.openList(item.source, null, null, item.source.getTitle(this)) + } } override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { - viewModel.addSource(item.source) + if (viewModel.isPresetMode) { + viewModel.togglePresetSource(item.source) + } else { + viewModel.addSource(item.source) + } return false } 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..418d46d280 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 @@ -13,6 +13,8 @@ import io.github.landwarderer.futon.core.ui.util.ReversibleAction import io.github.landwarderer.futon.core.util.ext.MutableEventFlow import io.github.landwarderer.futon.core.util.ext.call import io.github.landwarderer.futon.explore.data.MangaSourcesRepository +import io.github.landwarderer.futon.explore.data.SourcePreset +import io.github.landwarderer.futon.explore.data.SourcePresetsRepository import io.github.landwarderer.futon.explore.data.SourcesSortOrder import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.LoadingState @@ -22,8 +24,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import java.util.EnumSet @@ -34,10 +39,27 @@ import javax.inject.Inject class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, private val mihonExtensionManager: MihonExtensionManager, + private val presetsRepository: SourcePresetsRepository, db: MangaDatabase, settings: AppSettings, ) : BaseViewModel() { + private val activePresetId = settings.activeSourcePresetId + private val presetMutex = Mutex() + + /** The preset being edited: while one is active the catalog adds sources to it instead of enabling them. */ + private val activePresetFlow = if (activePresetId == SourcePresetsRepository.NO_PRESET) { + flowOf(null) + } else { + presetsRepository.observe(activePresetId) + } + + val activePreset: StateFlow = activePresetFlow + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, null) + + val isPresetMode: Boolean + get() = activePreset.value != null + val onActionDone = MutableEventFlow() val locales: Set = buildSet { repository.allMangaSources.forEach { add(it.locale) } @@ -65,15 +87,22 @@ class SourcesCatalogViewModel @Inject constructor( appliedFilter, db.invalidationTrackerFlow(TABLE_SOURCES), mihonExtensionManager.installedExtensions, - ) { q, f, _, _ -> - buildSourcesList(f, q) - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState)) + activePresetFlow, + ) { q, f, _, _, preset -> + buildSourcesList(f, q, preset) + }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, listOf(LoadingState())) init { repository.clearNewSourcesBadge() launchJob(Dispatchers.IO) { contentTypes.value = getContentTypes(settings.isNsfwContentDisabled) } + launchJob(Dispatchers.IO) { + val presetLocale = presetsRepository.getById(activePresetId)?.languages?.firstOrNull() + if (presetLocale != null && presetLocale in locales) { + appliedFilter.value = appliedFilter.value.copy(locale = presetLocale) + } + } } fun performSearch(query: String?) { @@ -91,6 +120,18 @@ class SourcesCatalogViewModel @Inject constructor( } } + fun togglePresetSource(source: MangaSource) { + launchJob(Dispatchers.IO) { + presetMutex.withLock { + // Read the stored set, not the observed one, so quick successive taps never undo each other. + val preset = presetsRepository.getById(activePresetId) ?: return@withLock + val name = source.name + val updated = if (name in preset.sources) preset.sources - name else preset.sources + name + presetsRepository.updatePresetSources(preset.id, updated) + } + } + } + fun setContentType(value: ContentType, isAdd: Boolean) { val filter = appliedFilter.value val types = EnumSet.noneOf(ContentType::class.java) @@ -107,11 +148,15 @@ class SourcesCatalogViewModel @Inject constructor( appliedFilter.value = appliedFilter.value.copy(isNewOnly = value) } - private suspend fun buildSourcesList(filter: SourcesCatalogFilter, query: String?): List { + private suspend fun buildSourcesList( + filter: SourcesCatalogFilter, + query: String?, + preset: SourcePreset?, + ): List { val sources = repository.queryParserSources( - isDisabledOnly = true, + isDisabledOnly = preset == null, isNewOnly = filter.isNewOnly, - excludeBroken = false, + excludeBroken = preset != null, types = filter.types, query = query, locale = filter.locale, @@ -135,7 +180,10 @@ class SourcesCatalogViewModel @Inject constructor( ) } else { sources.map { - SourceCatalogItem.Source(source = it) + SourceCatalogItem.Source( + source = it, + isInPreset = preset != null && it.name in preset.sources, + ) } } } 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..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,15 +1,18 @@ 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 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 +21,8 @@ class WorkScheduleManager @Inject constructor( private val suggestionScheduler: SuggestionsWorker.Scheduler, 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?) { @@ -52,6 +57,18 @@ 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() + } + 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/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/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 50bfdd8096..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 @@ -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 { @@ -211,15 +231,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()) { @@ -268,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() } } @@ -286,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()) @@ -395,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)) { @@ -471,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/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 6e83b6abd3..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 @@ -13,9 +13,47 @@ 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 + + /** 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") @@ -58,12 +96,26 @@ 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) + + /** 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/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..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 @@ -15,23 +15,29 @@ 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.model.MangaSource 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 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 +private const val MAX_QUERY_IDS = 500 @Reusable class TrackingRepository @Inject constructor( @@ -76,21 +82,53 @@ 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 + } + + 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 { @@ -144,8 +182,24 @@ 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)) db.getTrackLogsDao().run { gc() trim(MAX_LOG_SIZE) @@ -153,11 +207,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 }, @@ -167,6 +221,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) { @@ -207,11 +289,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 +306,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)) } } @@ -244,22 +330,37 @@ class TrackingRepository @Inject constructor( newChapters = newChapters, lastCheckTime = System.currentTimeMillis(), lastChapterDate = lastChapterDate, - lastResult = TrackEntity.RESULT_FAILED, + lastResult = updates.toResultCode(), 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, + ) + } } } + 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/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..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) { @@ -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/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 18a0b4b2c7..509d612c10 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,40 +34,54 @@ 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.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 +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 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 -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.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 import kotlinx.coroutines.CancellationException +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 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.Collections +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -81,18 +97,34 @@ 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 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 { + if (isExpiredManualRun()) { + // A manual check the system stopped (job time limit, doze, process death) is put back in the + // queue by WorkManager and may only run again hours later. Nobody is waiting for it any more, + // and with the tracker set to manual it looks like an automatic check: drop it instead. + return Result.success() + } 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 + val recheckSource = inputData.getString(KEY_SOURCE)?.let { MangaSource(it) } return try { - doWorkImpl(isFullRun = isForeground && TAG_ONESHOT in tags) + doWorkImpl(isFullRun = isFullRun, recheckSource = recheckSource) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -105,11 +137,22 @@ class TrackWorker @AssistedInject constructor( } } - private suspend fun doWorkImpl(isFullRun: Boolean): Result { + private fun isExpiredManualRun(): Boolean { + if (TAG_ONESHOT !in tags) return false + val requestedAt = inputData.getLong(KEY_REQUESTED_AT, 0L) + return requestedAt > 0L && System.currentTimeMillis() - requestedAt > MANUAL_RUN_EXPIRY_MS + } + + 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() } @@ -128,11 +171,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 -> @@ -140,20 +195,25 @@ 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) { - captchaHandler.handle(e) + val e = it.error?.findCloudFlareException() + if (e != null) { + captchaHandler.handle(e, tryAutoResolve = false) } } @@ -172,6 +232,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 { @@ -221,6 +286,8 @@ class TrackWorker @AssistedInject constructor( workManager.createCancelPendingIntent(id), ) setProgress(max, progress, max == 0) + // Never outlive a worker that was killed before it could cancel this notification + setTimeoutAfter(WORKER_NOTIFICATION_TIMEOUT_MS) setSmallIcon(android.R.drawable.stat_notify_sync) setForegroundServiceBehavior( if (TAG_ONESHOT in tags) { @@ -249,25 +316,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 @@ -315,13 +436,31 @@ class TrackWorker @AssistedInject constructor( val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG_ONESHOT) + .setInputData(workDataOf(KEY_REQUESTED_AT to System.currentTimeMillis())) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() 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 } @@ -339,7 +478,13 @@ 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 KEY_REQUESTED_AT = "requested_at" + val MANUAL_RUN_EXPIRY_MS = TimeUnit.MINUTES.toMillis(30) + val WORKER_NOTIFICATION_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(10) 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 } diff --git a/app/src/main/res/drawable/bg_badge_unavailable.xml b/app/src/main/res/drawable/bg_badge_unavailable.xml new file mode 100644 index 0000000000..d8a1f4ebf5 --- /dev/null +++ b/app/src/main/res/drawable/bg_badge_unavailable.xml @@ -0,0 +1,9 @@ + + + + + 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/layout/activity_alternative_covers.xml b/app/src/main/res/layout/activity_alternative_covers.xml new file mode 100644 index 0000000000..9f43a695bf --- /dev/null +++ b/app/src/main/res/layout/activity_alternative_covers.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + +