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