From 2c8476cabdc9fa42d813da34a144142c15db37dd Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 8 Mar 2025 14:13:44 +0200 Subject: [PATCH] Improve alternatives search functionality --- .../domain/AlternativesUseCase.kt | 42 ++++--- .../alternatives/domain/AutoFixUseCase.kt | 4 +- .../alternatives/ui/AlternativesActivity.kt | 12 +- .../alternatives/ui/AlternativesViewModel.kt | 115 ++++++++++++------ .../koitharu/kotatsu/core/util/ext/Flow.kt | 12 ++ .../kotatsu/details/ui/DetailsActivity.kt | 1 + .../koitharu/kotatsu/main/ui/MainActivity.kt | 3 +- .../search/ui/multi/SearchViewModel.kt | 4 +- gradle/libs.versions.toml | 2 +- 9 files changed, 138 insertions(+), 57 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index 673b8ee1e..5187ce892 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaParserSource @@ -14,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchV2Helper +import java.util.Locale import javax.inject.Inject private const val MAX_PARALLELISM = 4 @@ -24,8 +26,8 @@ class AlternativesUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, ) { - suspend operator fun invoke(manga: Manga): Flow { - val sources = getSources(manga.source) + suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow { + val sources = getSources(manga.source, throughDisabledSources) if (sources.isEmpty()) { return emptyFlow() } @@ -39,12 +41,14 @@ class AlternativesUseCase @Inject constructor( searchHelper(manga.title, SearchKind.TITLE)?.manga } }.getOrNull() - list?.forEach { - launch { - val details = runCatchingCancellable { - mangaRepositoryFactory.create(it.source).getDetails(it) - }.getOrDefault(it) - send(details) + list?.forEach { m -> + if (m.id != manga.id) { + launch { + val details = runCatchingCancellable { + mangaRepositoryFactory.create(m.source).getDetails(m) + }.getOrDefault(m) + send(details) + } } } } @@ -52,19 +56,23 @@ class AlternativesUseCase @Inject constructor( } } - private suspend fun getSources(ref: MangaSource): List { - val result = ArrayList(MangaParserSource.entries.size - 2) - result.addAll(sourcesRepository.getEnabledSources()) - result.sortByDescending { it.priority(ref) } - result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) - return result - } + private suspend fun getSources(ref: MangaSource, disabled: Boolean): List = if (disabled) { + sourcesRepository.getDisabledSources() + } else { + sourcesRepository.getEnabledSources() + }.sortedByDescending { it.priority(ref) } private fun MangaSource.priority(ref: MangaSource): Int { var res = 0 if (this is MangaParserSource && ref is MangaParserSource) { - if (locale == ref.locale) res += 2 - if (contentType == ref.contentType) res++ + if (locale == ref.locale) { + res += 4 + } else if (locale.toLocale() == Locale.getDefault()) { + res += 2 + } + if (contentType == ref.contentType) { + res++ + } } return res } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt index 96b5866b8..6a4f3aa58 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.util.ext.concat import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.concurrent.TimeUnit @@ -35,7 +36,8 @@ class AutoFixUseCase @Inject constructor( if (seed.isHealthy()) { return seed to null // no fix required } - val replacement = alternativesUseCase(seed) + val replacement = alternativesUseCase(seed, throughDisabledSources = false) + .concat(alternativesUseCase(seed, throughDisabledSources = true)) .filter { it.isHealthy() } .runningFold(null) { best, candidate -> if (best == null || best < candidate) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt index 87a1bc085..d3b022ee4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt @@ -22,7 +22,9 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration +import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD @@ -32,6 +34,7 @@ import javax.inject.Inject @AndroidEntryPoint class AlternativesActivity : BaseActivity(), + ListStateHolderListener, OnListItemClickListener { @Inject @@ -51,6 +54,7 @@ class AlternativesActivity : BaseActivity(), .addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, 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)) @@ -58,7 +62,7 @@ class AlternativesActivity : BaseActivity(), } viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.content.observe(this, listAdapter) + viewModel.list.observe(this, listAdapter) viewModel.onMigrated.observeEvent(this) { Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() router.openDetails(it) @@ -92,6 +96,12 @@ class AlternativesActivity : BaseActivity(), } } + override fun onRetryClick(error: Throwable) = viewModel.retry() + + override fun onEmptyActionClick() = Unit + + override fun onFooterButtonClick() = viewModel.continueSearch() + private fun confirmMigration(target: Manga) { buildAlertDialog(this, isCentered = true) { setIcon(R.drawable.ic_replace) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt index bc76db87f..5b3b37825 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt @@ -1,13 +1,17 @@ package org.koitharu.kotatsu.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.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase @@ -18,16 +22,19 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.append import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.list.domain.MangaListMapper +import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import javax.inject.Inject @HiltViewModel @@ -41,39 +48,62 @@ class AlternativesViewModel @Inject constructor( val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga - val onMigrated = MutableEventFlow() - val content = MutableStateFlow>(listOf(LoadingState)) + private var includeDisabledSources = MutableStateFlow(false) + private val results = MutableStateFlow>(emptyList()) + private var migrationJob: Job? = null + private var searchJob: Job? = null + + private val mangaDetails = suspendLazy { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + } + + val onMigrated = MutableEventFlow() + + val list: StateFlow> = combine( + results, + isLoading, + includeDisabledSources, + ) { list, loading, includeDisabled -> + when { + list.isEmpty() -> listOf( + when { + loading -> LoadingState + else -> EmptyState( + icon = R.drawable.ic_empty_common, + textPrimary = R.string.nothing_found, + textSecondary = R.string.text_search_holder_secondary, + actionStringRes = 0, + ) + }, + ) + + loading -> list + LoadingFooter() + includeDisabled -> list + else -> list + ButtonFooter(R.string.search_disabled_sources) + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { - launchJob(Dispatchers.Default) { - val ref = runCatchingCancellable { - mangaRepositoryFactory.create(manga.source).getDetails(manga) - }.getOrDefault(manga) - val refCount = ref.chaptersCount() - alternativesUseCase(ref) - .map { - MangaAlternativeModel( - mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel, - referenceChapters = refCount, - ) - }.runningFold>(listOf(LoadingState)) { acc, item -> - acc.filterIsInstance() + item + LoadingFooter() - }.onEmpty { - emit( - listOf( - EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_search_holder_secondary, - actionStringRes = 0, - ), - ), - ) - }.collect { - content.value = it - } - content.value = content.value.filterNot { it is LoadingFooter } + doSearch(throughDisabledSources = false) + } + + fun retry() { + searchJob?.cancel() + results.value = emptyList() + includeDisabledSources.value = false + doSearch(throughDisabledSources = false) + } + + fun continueSearch() { + if (includeDisabledSources.value) { + return + } + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + includeDisabledSources.value = true + prevJob?.join() + doSearch(throughDisabledSources = true) } } @@ -86,4 +116,21 @@ class AlternativesViewModel @Inject constructor( onMigrated.call(target) } } + + private fun doSearch(throughDisabledSources: Boolean) { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + 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) + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index 9007f8fa9..214f3b349 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -4,10 +4,12 @@ import android.os.SystemClock import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest @@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.update import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import java.util.concurrent.TimeUnit @@ -142,3 +145,12 @@ suspend fun SendChannel.sendNotNull(item: T?) { send(item) } } + +fun MutableStateFlow>.append(item: T) { + update { list -> list + item } +} + +fun Flow.concat(other: Flow) = flow { + emitAll(this@concat) + emitAll(other) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index e69c0d40a..6f9a81804 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -155,6 +155,7 @@ class DetailsActivity : viewBinding.chipsTags.onChipClickListener = this TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) viewBinding.containerBottomSheet?.let { sheet -> + sheet.setOnClickListener(this) sheet.addOnLayoutChangeListener(this) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) BottomSheetBehavior.from(sheet).addBottomSheetCallback( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index c958dbf71..3e5a394a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -341,7 +341,8 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.fab?.isEnabled = !isLoading + val fab = viewBinding.fab ?: viewBinding.navRail?.headerView ?: return + fab.isEnabled = !isLoading } private fun onResumeEnabledChanged(isEnabled: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 751224718..c4a3c2e57 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus @@ -25,6 +24,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.append import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository @@ -284,7 +284,7 @@ class SearchViewModel @Inject constructor( private fun appendResult(item: SearchResultsListModel?) { if (item != null) { - results.update { list -> list + item } + results.append(item) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86f5b3958..da265fe5f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ material = "1.13.0-alpha11" moshi = "1.15.2" okhttp = "4.12.0" okio = "3.10.2" -parsers = "77a5216ebf" +parsers = "d5a4cf68c6" preference = "1.2.1" recyclerview = "1.4.0" room = "2.6.1"