From 4449996a91bd25abb46390dad483f6a8d9c97daf Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 27 Apr 2025 15:28:48 +0300 Subject: [PATCH] Fix search suggestions (cherry picked from commit 1a8045b89f04f94f0475e5025a866c7ed440215c) --- .../search/domain/MangaSearchRepository.kt | 2 +- .../ui/suggestion/SearchSuggestionFragment.kt | 17 +- .../suggestion/SearchSuggestionViewModel.kt | 153 ++++++++++++------ .../adapter/SearchSuggestionAdapter.kt | 1 + .../adapter/SearchSuggestionTextAD.kt | 28 ++++ .../suggestion/model/SearchSuggestionItem.kt | 12 ++ .../kotatsu/suggestions/data/SuggestionDao.kt | 24 ++- .../domain/SuggestionRepository.kt | 7 +- .../layout/item_search_suggestion_text.xml | 14 ++ 9 files changed, 185 insertions(+), 73 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTextAD.kt create mode 100644 app/src/main/res/layout/item_search_suggestion_text.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index 924334415..75ed86995 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -37,7 +37,7 @@ class MangaSearchRepository @Inject constructor( suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List { return when { - query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) } + query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) } source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit) else -> db.getMangaDao().searchByTitle("%$query%", limit) }.let { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 08bc21bdf..126a1a3d7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -9,11 +9,13 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ItemTouchHelper import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.os.VoiceInputContract import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter import javax.inject.Inject @@ -49,19 +51,16 @@ class SearchSuggestionFragment : binding.root.adapter = adapter binding.root.setHasFixedSize(true) viewModel.suggestion.observe(viewLifecycleOwner, adapter) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.root, this)) ItemTouchHelper(SearchSuggestionItemCallback(this)) .attachToRecyclerView(binding.root) } override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { - val barsInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()) - v.setPadding( - barsInsets.left, - 0, - barsInsets.right, - barsInsets.bottom, - ) - return insets.consumeAllSystemBarsInsets() + val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + v.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom) + return insets.consumeAll(typeMask) } override fun onRemoveQuery(query: String) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index 28b1f4125..d4c779413 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -21,11 +21,12 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.sizeOrZero +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import javax.inject.Inject @@ -87,7 +88,7 @@ class SearchSuggestionViewModel @Inject constructor( } fun onResume() { - if (invalidateOnResume) { + if (invalidateOnResume || suggestionJob?.isActive != true) { invalidateOnResume = false setupSuggestion() } @@ -120,62 +121,114 @@ class SearchSuggestionViewModel @Inject constructor( enabledSources: Set, types: Set, ): List = coroutineScope { - val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) { - async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) } + listOfNotNull( + if (SearchSuggestionType.GENRES in types) { + async { getTags(searchQuery) } + } else { + null + }, + if (SearchSuggestionType.MANGA in types) { + async { getManga(searchQuery) } + } else { + null + }, + if (SearchSuggestionType.QUERIES_RECENT in types) { + async { getRecentQueries(searchQuery) } + } else { + null + }, + if (SearchSuggestionType.QUERIES_SUGGEST in types) { + async { getQueryHints(searchQuery) } + } else { + null + }, + if (SearchSuggestionType.SOURCES in types) { + async { getSources(searchQuery, enabledSources) } + } else { + null + }, + if (SearchSuggestionType.RECENT_SOURCES in types) { + async { getRecentSources(searchQuery) } + } else { + null + }, + if (SearchSuggestionType.AUTHORS in types) { + async { + getAuthors(searchQuery) + } + } else { + null + }, + ).flatMap { it.await() } + } + + private suspend fun getAuthors(searchQuery: String): List = runCatchingCancellable { + repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) + .map { SearchSuggestionItem.Author(it) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) + } + + private suspend fun getQueryHints(searchQuery: String): List = runCatchingCancellable { + repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) + .map { SearchSuggestionItem.Hint(it) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) + } + + private suspend fun getRecentQueries(searchQuery: String): List = runCatchingCancellable { + repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) + .map { SearchSuggestionItem.RecentQuery(it) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) + } + + private suspend fun getTags(searchQuery: String): List = runCatchingCancellable { + val tags = repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) + if (tags.isEmpty()) { + emptyList() } else { - null + listOf(SearchSuggestionItem.Tags(mapTags(tags))) } - val hintsDeferred = if (SearchSuggestionType.QUERIES_SUGGEST in types) { - async { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) + } + + private suspend fun getManga(searchQuery: String): List = runCatchingCancellable { + val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) + if (manga.isEmpty()) { + emptyList() } else { - null + listOf(SearchSuggestionItem.MangaList(manga)) } - val authorsDeferred = if (SearchSuggestionType.AUTHORS in types) { - async { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) } - } else { - null - } - val tagsDeferred = if (SearchSuggestionType.GENRES in types) { - async { repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) } - } else { - null - } - val mangaDeferred = if (SearchSuggestionType.MANGA in types) { - async { repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) } - } else { - null - } - val sources = if (SearchSuggestionType.SOURCES in types) { + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) + } + + private suspend fun getSources(searchQuery: String, enabledSources: Set): List = + runCatchingCancellable { repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS) - } else { - null - } - val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) { - async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) } - } else { - null + .map { SearchSuggestionItem.Source(it, it.name in enabledSources) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) } - val tags = tagsDeferred?.await() - val mangaList = mangaDeferred?.await() - val queries = queriesDeferred?.await() - val hints = hintsDeferred?.await() - val authors = authorsDeferred?.await() - val sourcesTips = sourcesTipsDeferred?.await() - - buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) { - if (!tags.isNullOrEmpty()) { - add(SearchSuggestionItem.Tags(mapTags(tags))) - } - if (!mangaList.isNullOrEmpty()) { - add(SearchSuggestionItem.MangaList(mangaList)) - } - sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) } - queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } - authors?.mapTo(this) { SearchSuggestionItem.Author(it) } - hints?.mapTo(this) { SearchSuggestionItem.Hint(it) } - sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) } + private suspend fun getRecentSources(searchQuery: String): List = if (searchQuery.isEmpty()) { + runCatchingCancellable { + repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) + .map { SearchSuggestionItem.SourceTip(it) } + }.getOrElse { e -> + e.printStackTraceDebug() + listOf(SearchSuggestionItem.Text(0, e)) } + } else { + emptyList() } private fun mapTags(tags: List): List = tags.map { tag -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt index a788c6b88..63609b77f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt @@ -23,5 +23,6 @@ class SearchSuggestionAdapter( .addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener)) .addDelegate(searchSuggestionQueryHintAD(listener)) .addDelegate(searchSuggestionAuthorAD(listener)) + .addDelegate(searchSuggestionTextAD()) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTextAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTextAD.kt new file mode 100644 index 000000000..5b1f47978 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTextAD.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.search.ui.suggestion.adapter + +import android.widget.TextView +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem + +fun searchSuggestionTextAD() = adapterDelegate( + R.layout.item_search_suggestion_text, +) { + + bind { + val tv = itemView as TextView + val isError = item.error != null + tv.setCompoundDrawablesRelativeWithIntrinsicBounds( + if (isError) R.drawable.ic_error_small else 0, + 0, + 0, + 0, + ) + if (item.textResId != 0) { + tv.setText(item.textResId) + } else { + tv.text = item.error?.getDisplayMessage(tv.resources) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt index 27f877460..a0a428f1a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.search.ui.suggestion.model +import androidx.annotation.StringRes import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.ui.ListModelDiffCallback @@ -93,4 +94,15 @@ sealed interface SearchSuggestionItem : ListModel { return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED } } + + data class Text( + @StringRes val textResId: Int, + val error: Throwable?, + ) : SearchSuggestionItem { + + override fun areItemsTheSame(other: ListModel): Boolean = other is Text + && textResId == other.textResId + && error?.javaClass == other.error?.javaClass + && error?.message == other.error?.message + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt index f61d88f3f..12e0eb5b3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -11,6 +11,7 @@ import androidx.room.Update import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaQueryBuilder +import org.koitharu.kotatsu.core.db.entity.MangaWithTags import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.list.domain.ListFilterOption @@ -33,12 +34,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { ) @Transaction - @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1") - abstract suspend fun getRandom(): SuggestionWithManga? - - @Transaction - @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit") - abstract suspend fun getRandom(limit: Int): List + open suspend fun getRandom(limit: Int): List { + val ids = getRandomIds(limit) + return getByIds(ids) + } @Query("SELECT COUNT(*) FROM suggestions") abstract suspend fun count(): Int @@ -68,6 +67,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { } } + @Query("SELECT * FROM manga WHERE manga_id IN (:ids)") + protected abstract suspend fun getByIds(ids: LongArray): List + + @Query("SELECT manga_id FROM suggestions ORDER BY RANDOM() LIMIT :limit") + protected abstract suspend fun getRandomIds(limit: Int): LongArray + @Transaction @RawQuery(observedEntities = [SuggestionEntity::class]) protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> @@ -75,7 +80,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback { override fun getCondition(option: ListFilterOption): String? = when (option) { ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})" - is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}" + is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${ + sqlEscapeString( + option.mangaSource.name, + ) + }" + else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index 6a2e168cf..4e2cacc93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTagsList import org.koitharu.kotatsu.core.model.toMangaSources import org.koitharu.kotatsu.core.util.ext.mapItems @@ -34,10 +33,6 @@ class SuggestionRepository @Inject constructor( } } - suspend fun getRandom(): Manga? { - return db.getSuggestionDao().getRandom()?.toManga() - } - suspend fun getRandomList(limit: Int): List { return db.getSuggestionDao().getRandom(limit).map { it.toManga() @@ -80,5 +75,5 @@ class SuggestionRepository @Inject constructor( } } - private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) + private fun SuggestionWithManga.toManga() = manga.toManga(emptySet(), null) } diff --git a/app/src/main/res/layout/item_search_suggestion_text.xml b/app/src/main/res/layout/item_search_suggestion_text.xml new file mode 100644 index 000000000..5440f9815 --- /dev/null +++ b/app/src/main/res/layout/item_search_suggestion_text.xml @@ -0,0 +1,14 @@ + +