From 93e8e87b039e0ecc179f0f78a01eea9b69ef1e72 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 3 Mar 2025 17:34:00 +0200 Subject: [PATCH] Improve global search --- app/build.gradle | 4 +- .../koitharu/kotatsu/core/util/ext/Flow.kt | 7 + .../kotatsu/search/domain/SearchV2Helper.kt | 9 + .../kotatsu/search/ui/multi/SearchActivity.kt | 4 +- .../search/ui/multi/SearchViewModel.kt | 329 ++++++++++-------- .../search/ui/multi/adapter/SearchAdapter.kt | 2 + .../settings/backup/BackupViewModel.kt | 3 +- app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 2 +- 9 files changed, 213 insertions(+), 148 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6096909c8..ebf73e22e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,8 +19,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 1002 - versionName = '8.0-b2' + versionCode = 1003 + versionName = '8.0-b3' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { 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 cd4a04054..9007f8fa9 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 @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.util.ext import android.os.SystemClock +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -135,3 +136,9 @@ suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x ! fun Flow>.flattenLatest() = flatMapLatest { it } fun SuspendLazy.asFlow() = flow { emit(runCatchingCancellable { get() }) } + +suspend fun SendChannel.sendNotNull(item: T?) { + if (item != null) { + send(item) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt index a4006ca02..b12dfc8eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt @@ -3,8 +3,10 @@ package org.koitharu.kotatsu.search.domain import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -20,9 +22,13 @@ class SearchV2Helper @AssistedInject constructor( @Assisted private val source: MangaSource, private val mangaRepositoryFactory: MangaRepository.Factory, private val dataRepository: MangaDataRepository, + private val settings: AppSettings, ) { suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? { + if (settings.isNsfwContentDisabled && source.isNsfw()) { + return null + } val repository = mangaRepositoryFactory.create(source) val listFilter = repository.getFilter(query, kind) ?: return null val sortOrder = repository.getSortOrder(kind) @@ -68,6 +74,9 @@ class SearchV2Helper @AssistedInject constructor( } private fun MutableList.postFilter(query: String, kind: SearchKind) { + if (settings.isNsfwContentDisabled) { + removeAll { it.isNsfw } + } when (kind) { SearchKind.TITLE -> retainAll { m -> m.matches(query, MATCH_THRESHOLD_DEFAULT) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index ac75fcd2e..65fc666b2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -140,10 +140,12 @@ class SearchActivity : override fun onFilterClick(view: View?) = Unit - override fun onEmptyActionClick() = Unit + override fun onEmptyActionClick() = viewModel.continueSearch() override fun onListHeaderClick(item: ListHeader, view: View) = Unit + override fun onFooterButtonClick() = viewModel.continueSearch() + override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit 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 ca30a475e..751224718 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 @@ -1,22 +1,19 @@ package org.koitharu.kotatsu.search.ui.multi -import androidx.annotation.CheckResult +import androidx.collection.ArraySet import androidx.collection.LongSet import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus @@ -29,18 +26,23 @@ 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.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository 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.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 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 @@ -58,15 +60,16 @@ class SearchViewModel @Inject constructor( val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() val kind = savedStateHandle.get(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE - private val retryCounter = MutableStateFlow(0) - private val listData = retryCounter.flatMapLatest { - searchImpl().withLoading().withErrorHandling() - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + private var includeDisabledSources = MutableStateFlow(false) + private val results = MutableStateFlow>(emptyList()) + + private var searchJob: Job? = null val list: StateFlow> = combine( - listData.filterNotNull(), + results, isLoading, - ) { list, loading -> + includeDisabledSources, + ) { list, loading, includeDisabled -> when { list.isEmpty() -> listOf( when { @@ -81,13 +84,18 @@ class SearchViewModel @Inject constructor( ) loading -> list + LoadingFooter() - else -> list + includeDisabled -> list + else -> list + ButtonFooter(R.string.search_disabled_sources) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + init { + doSearch() + } + fun getItems(ids: LongSet): Set { - val snapshot = listData.value ?: return emptySet() - val result = HashSet(ids.size) + val snapshot = results.value + val result = ArraySet(ids.size) snapshot.forEach { x -> for (item in x.list) { if (item.id in ids) { @@ -99,157 +107,192 @@ class SearchViewModel @Inject constructor( } fun retry() { - retryCounter.value += 1 + searchJob?.cancel() + results.value = emptyList() + includeDisabledSources.value = false + doSearch() } - @CheckResult - private fun searchImpl(): Flow> = channelFlow { - searchHistory()?.let { send(it) } - searchFavorites()?.let { send(it) } - searchLocal()?.let { send(it) } - val sources = sourcesRepository.getEnabledSources() - if (sources.isEmpty()) { - return@channelFlow + fun continueSearch() { + if (includeDisabledSources.value) { + return } - val semaphore = Semaphore(MAX_PARALLELISM) - sources.map { source -> - launch { - val item = runCatchingCancellable { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + includeDisabledSources.value = true + prevJob?.join() + val sources = sourcesRepository.getDisabledSources() + .sortedByDescending { it.priority() } + val semaphore = Semaphore(MAX_PARALLELISM) + sources.map { source -> + launch { semaphore.withPermit { - val searchHelper = searchHelperFactory.create(source) - searchHelper(query, kind) + appendResult(searchSource(source)) } - }.fold( - onSuccess = { result -> - if (result == null || result.manga.isEmpty()) { - null - } else { - val list = mangaListMapper.toListModelList( - manga = result.manga, - mode = ListMode.GRID, - ) - SearchResultsListModel( - titleResId = 0, - source = source, - list = list, - error = null, - listFilter = result.listFilter, - sortOrder = result.sortOrder, - ) - } - }, - onFailure = { error -> - error.printStackTraceDebug() - SearchResultsListModel(0, source, null, null, emptyList(), error) - }, - ) - if (item != null) { - send(item) } - } - }.joinAll() - }.runningFold?>(null) { list, item -> list.orEmpty() + item } - .filterNotNull() - .onEmpty { emit(emptyList()) } - - private suspend fun searchHistory(): SearchResultsListModel? { - return runCatchingCancellable { - historyRepository.search(query, kind, Int.MAX_VALUE) - }.fold( - onSuccess = { result -> - if (result.isNotEmpty()) { - SearchResultsListModel( - titleResId = R.string.history, - source = UnknownMangaSource, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), - error = null, - listFilter = null, - sortOrder = null, - ) - } else { - null + }.joinAll() + } + } + + private fun doSearch() { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + appendResult(searchHistory()) + appendResult(searchFavorites()) + appendResult(searchLocal()) + val sources = sourcesRepository.getEnabledSources() + val semaphore = Semaphore(MAX_PARALLELISM) + sources.map { source -> + launch { + semaphore.withPermit { + appendResult(searchSource(source)) + } } - }, - onFailure = { error -> + }.joinAll() + } + } + + // impl + + private suspend fun searchSource(source: MangaSource): SearchResultsListModel? = runCatchingCancellable { + val searchHelper = searchHelperFactory.create(source) + searchHelper(query, kind) + }.fold( + onSuccess = { result -> + if (result == null || result.manga.isEmpty()) { + null + } else { + val list = mangaListMapper.toListModelList( + manga = result.manga, + mode = ListMode.GRID, + ) + SearchResultsListModel( + titleResId = 0, + source = source, + list = list, + error = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, + ) + } + }, + onFailure = { error -> + error.printStackTraceDebug() + if (source is MangaParserSource && source.isBroken) { + null + } else { + SearchResultsListModel(0, source, null, null, emptyList(), error) + } + }, + ) + + private suspend fun searchHistory(): SearchResultsListModel? = runCatchingCancellable { + historyRepository.search(query, kind, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.history, source = UnknownMangaSource, - list = emptyList(), - error = error, + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + error = null, listFilter = null, sortOrder = null, ) - }, - ) - } + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.history, + source = UnknownMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) - private suspend fun searchFavorites(): SearchResultsListModel? { - return runCatchingCancellable { - favouritesRepository.search(query, kind, Int.MAX_VALUE) - }.fold( - onSuccess = { result -> - if (result.isNotEmpty()) { - SearchResultsListModel( - titleResId = R.string.favourites, - source = UnknownMangaSource, - list = mangaListMapper.toListModelList( - manga = result, - mode = ListMode.GRID, - flags = MangaListMapper.NO_FAVORITE, - ), - error = null, - listFilter = null, - sortOrder = null, - ) - } else { - null - } - }, - onFailure = { error -> + private suspend fun searchFavorites(): SearchResultsListModel? = runCatchingCancellable { + favouritesRepository.search(query, kind, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.favourites, source = UnknownMangaSource, - list = emptyList(), - error = error, + list = mangaListMapper.toListModelList( + manga = result, + mode = ListMode.GRID, + flags = MangaListMapper.NO_FAVORITE, + ), + error = null, listFilter = null, sortOrder = null, ) - }, - ) - } + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.favourites, + source = UnknownMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) - private suspend fun searchLocal(): SearchResultsListModel? { - return runCatchingCancellable { - searchHelperFactory.create(LocalMangaSource).invoke(query, kind) - }.fold( - onSuccess = { result -> - if (!result?.manga.isNullOrEmpty()) { - SearchResultsListModel( - titleResId = 0, - source = LocalMangaSource, - list = mangaListMapper.toListModelList( - manga = result.manga, - mode = ListMode.GRID, - flags = MangaListMapper.NO_SAVED, - ), - error = null, - listFilter = result.listFilter, - sortOrder = result.sortOrder, - ) - } else { - null - } - }, - onFailure = { error -> + private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable { + searchHelperFactory.create(LocalMangaSource).invoke(query, kind) + }.fold( + onSuccess = { result -> + if (!result?.manga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, source = LocalMangaSource, - list = emptyList(), - error = error, - listFilter = null, - sortOrder = null, + list = mangaListMapper.toListModelList( + manga = result.manga, + mode = ListMode.GRID, + flags = MangaListMapper.NO_SAVED, + ), + error = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, ) - }, - ) + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = 0, + source = LocalMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) + + private fun appendResult(item: SearchResultsListModel?) { + if (item != null) { + results.update { list -> list + item } + } + } + + private fun MangaSource.priority(): Int { + var res = 0 + if (this is MangaParserSource) { + if (locale.toLocale() == Locale.getDefault()) res += 2 + } + return res } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt index d14206a68..a7c7323dd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListListener +import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD @@ -45,6 +46,7 @@ class SearchAdapter( addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) + addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener)) } override fun getSectionText(context: Context, position: Int): CharSequence? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index e6341c26d..484b8a8ff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import okio.FileNotFoundException import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -67,7 +68,7 @@ class BackupViewModel @Inject constructor( fun saveBackup(output: Uri) { launchLoadingJob(Dispatchers.Default) { - val file = checkNotNull(backupFile) + val file = backupFile ?: throw FileNotFoundException() contentResolver.openFileDescriptor(output, "w")?.use { fd -> FileOutputStream(fd.fileDescriptor).use { it.write(file.readBytes()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1031e5d3e..9a54378db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,5 @@ Vol %1$s Chapter %2$s Chapter %s Unnamed chapter + Search through disabled sources diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 636b781b9..86f5b3958 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 = "531145c7f9" +parsers = "77a5216ebf" preference = "1.2.1" recyclerview = "1.4.0" room = "2.6.1"