Improve global search

This commit is contained in:
Koitharu
2025-03-03 17:34:00 +02:00
parent 09590cfab0
commit 93e8e87b03
9 changed files with 212 additions and 147 deletions

View File

@@ -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 {

View File

@@ -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 <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }
suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
if (item != null) {
send(item)
}
}

View File

@@ -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<Manga>.postFilter(query: String, kind: SearchKind) {
if (settings.isNsfwContentDisabled) {
removeAll { it.isNsfw }
}
when (kind) {
SearchKind.TITLE -> retainAll { m ->
m.matches(query, MATCH_THRESHOLD_DEFAULT)

View File

@@ -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

View File

@@ -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<String>(AppRouter.KEY_QUERY).orEmpty()
val kind = savedStateHandle.get<SearchKind>(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<List<SearchResultsListModel>>(emptyList())
private var searchJob: Job? = null
val list: StateFlow<List<ListModel>> = 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<Manga> {
val snapshot = listData.value ?: return emptySet()
val result = HashSet<Manga>(ids.size)
val snapshot = results.value
val result = ArraySet<Manga>(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<List<SearchResultsListModel>> = 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<SearchResultsListModel, List<SearchResultsListModel>?>(null) { list, item -> list.orEmpty() + item }
.filterNotNull()
.onEmpty { emit(emptyList()) }
}.joinAll()
}
}
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
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
}
}

View File

@@ -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? {

View File

@@ -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())

View File

@@ -809,4 +809,5 @@
<string name="chapter_volume_number">Vol %1$s Chapter %2$s</string>
<string name="chapter_number">Chapter %s</string>
<string name="unnamed_chapter">Unnamed chapter</string>
<string name="search_disabled_sources">Search through disabled sources</string>
</resources>

View File

@@ -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"