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' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 1002 versionCode = 1003
versionName = '8.0-b2' versionName = '8.0-b3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.os.SystemClock import android.os.SystemClock
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow 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> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) } 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.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository 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.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -20,9 +22,13 @@ class SearchV2Helper @AssistedInject constructor(
@Assisted private val source: MangaSource, @Assisted private val source: MangaSource,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val settings: AppSettings,
) { ) {
suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? { suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? {
if (settings.isNsfwContentDisabled && source.isNsfw()) {
return null
}
val repository = mangaRepositoryFactory.create(source) val repository = mangaRepositoryFactory.create(source)
val listFilter = repository.getFilter(query, kind) ?: return null val listFilter = repository.getFilter(query, kind) ?: return null
val sortOrder = repository.getSortOrder(kind) val sortOrder = repository.getSortOrder(kind)
@@ -68,6 +74,9 @@ class SearchV2Helper @AssistedInject constructor(
} }
private fun MutableList<Manga>.postFilter(query: String, kind: SearchKind) { private fun MutableList<Manga>.postFilter(query: String, kind: SearchKind) {
if (settings.isNsfwContentDisabled) {
removeAll { it.isNsfw }
}
when (kind) { when (kind) {
SearchKind.TITLE -> retainAll { m -> SearchKind.TITLE -> retainAll { m ->
m.matches(query, MATCH_THRESHOLD_DEFAULT) m.matches(query, MATCH_THRESHOLD_DEFAULT)

View File

@@ -140,10 +140,12 @@ class SearchActivity :
override fun onFilterClick(view: View?) = Unit 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 onListHeaderClick(item: ListHeader, view: View) = Unit
override fun onFooterButtonClick() = viewModel.continueSearch()
override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onPrimaryButtonClick(tipView: TipView) = Unit
override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit

View File

@@ -1,22 +1,19 @@
package org.koitharu.kotatsu.search.ui.multi package org.koitharu.kotatsu.search.ui.multi
import androidx.annotation.CheckResult import androidx.collection.ArraySet
import androidx.collection.LongSet import androidx.collection.LongSet
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine 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.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus 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.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug 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.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.MangaListMapper 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.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga 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.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper import org.koitharu.kotatsu.search.domain.SearchV2Helper
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@@ -58,15 +60,16 @@ class SearchViewModel @Inject constructor(
val query = savedStateHandle.get<String>(AppRouter.KEY_QUERY).orEmpty() val query = savedStateHandle.get<String>(AppRouter.KEY_QUERY).orEmpty()
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
private val retryCounter = MutableStateFlow(0) private var includeDisabledSources = MutableStateFlow(false)
private val listData = retryCounter.flatMapLatest { private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
searchImpl().withLoading().withErrorHandling()
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) private var searchJob: Job? = null
val list: StateFlow<List<ListModel>> = combine( val list: StateFlow<List<ListModel>> = combine(
listData.filterNotNull(), results,
isLoading, isLoading,
) { list, loading -> includeDisabledSources,
) { list, loading, includeDisabled ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
when { when {
@@ -81,13 +84,18 @@ class SearchViewModel @Inject constructor(
) )
loading -> list + LoadingFooter() loading -> list + LoadingFooter()
else -> list includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
doSearch()
}
fun getItems(ids: LongSet): Set<Manga> { fun getItems(ids: LongSet): Set<Manga> {
val snapshot = listData.value ?: return emptySet() val snapshot = results.value
val result = HashSet<Manga>(ids.size) val result = ArraySet<Manga>(ids.size)
snapshot.forEach { x -> snapshot.forEach { x ->
for (item in x.list) { for (item in x.list) {
if (item.id in ids) { if (item.id in ids) {
@@ -99,157 +107,192 @@ class SearchViewModel @Inject constructor(
} }
fun retry() { fun retry() {
retryCounter.value += 1 searchJob?.cancel()
results.value = emptyList()
includeDisabledSources.value = false
doSearch()
} }
@CheckResult fun continueSearch() {
private fun searchImpl(): Flow<List<SearchResultsListModel>> = channelFlow { if (includeDisabledSources.value) {
searchHistory()?.let { send(it) } return
searchFavorites()?.let { send(it) }
searchLocal()?.let { send(it) }
val sources = sourcesRepository.getEnabledSources()
if (sources.isEmpty()) {
return@channelFlow
} }
val semaphore = Semaphore(MAX_PARALLELISM) val prevJob = searchJob
sources.map { source -> searchJob = launchLoadingJob(Dispatchers.Default) {
launch { includeDisabledSources.value = true
val item = runCatchingCancellable { prevJob?.join()
val sources = sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() }
val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source ->
launch {
semaphore.withPermit { semaphore.withPermit {
val searchHelper = searchHelperFactory.create(source) appendResult(searchSource(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()
SearchResultsListModel(0, source, null, null, emptyList(), error)
},
)
if (item != null) {
send(item)
} }
} }.joinAll()
}.joinAll() }
}.runningFold<SearchResultsListModel, List<SearchResultsListModel>?>(null) { list, item -> list.orEmpty() + item } }
.filterNotNull()
.onEmpty { emit(emptyList()) }
private suspend fun searchHistory(): SearchResultsListModel? { private fun doSearch() {
return runCatchingCancellable { val prevJob = searchJob
historyRepository.search(query, kind, Int.MAX_VALUE) searchJob = launchLoadingJob(Dispatchers.Default) {
}.fold( prevJob?.cancelAndJoin()
onSuccess = { result -> appendResult(searchHistory())
if (result.isNotEmpty()) { appendResult(searchFavorites())
SearchResultsListModel( appendResult(searchLocal())
titleResId = R.string.history, val sources = sourcesRepository.getEnabledSources()
source = UnknownMangaSource, val semaphore = Semaphore(MAX_PARALLELISM)
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), sources.map { source ->
error = null, launch {
listFilter = null, semaphore.withPermit {
sortOrder = null, appendResult(searchSource(source))
) }
} else {
null
} }
}, }.joinAll()
onFailure = { error -> }
}
// 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( SearchResultsListModel(
titleResId = R.string.history, titleResId = R.string.history,
source = UnknownMangaSource, source = UnknownMangaSource,
list = emptyList(), list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
error = error, error = null,
listFilter = null, listFilter = null,
sortOrder = 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? { private suspend fun searchFavorites(): SearchResultsListModel? = runCatchingCancellable {
return runCatchingCancellable { favouritesRepository.search(query, kind, Int.MAX_VALUE)
favouritesRepository.search(query, kind, Int.MAX_VALUE) }.fold(
}.fold( onSuccess = { result ->
onSuccess = { result -> if (result.isNotEmpty()) {
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 ->
SearchResultsListModel( SearchResultsListModel(
titleResId = R.string.favourites, titleResId = R.string.favourites,
source = UnknownMangaSource, source = UnknownMangaSource,
list = emptyList(), list = mangaListMapper.toListModelList(
error = error, manga = result,
mode = ListMode.GRID,
flags = MangaListMapper.NO_FAVORITE,
),
error = null,
listFilter = null, listFilter = null,
sortOrder = 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? { private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable {
return runCatchingCancellable { searchHelperFactory.create(LocalMangaSource).invoke(query, kind)
searchHelperFactory.create(LocalMangaSource).invoke(query, kind) }.fold(
}.fold( onSuccess = { result ->
onSuccess = { result -> if (!result?.manga.isNullOrEmpty()) {
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 ->
SearchResultsListModel( SearchResultsListModel(
titleResId = 0, titleResId = 0,
source = LocalMangaSource, source = LocalMangaSource,
list = emptyList(), list = mangaListMapper.toListModelList(
error = error, manga = result.manga,
listFilter = null, mode = ListMode.GRID,
sortOrder = null, 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.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener 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.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
@@ -45,6 +46,7 @@ class SearchAdapter(
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener))
addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener))
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { 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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import okio.FileNotFoundException
import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -67,7 +68,7 @@ class BackupViewModel @Inject constructor(
fun saveBackup(output: Uri) { fun saveBackup(output: Uri) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val file = checkNotNull(backupFile) val file = backupFile ?: throw FileNotFoundException()
contentResolver.openFileDescriptor(output, "w")?.use { fd -> contentResolver.openFileDescriptor(output, "w")?.use { fd ->
FileOutputStream(fd.fileDescriptor).use { FileOutputStream(fd.fileDescriptor).use {
it.write(file.readBytes()) 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_volume_number">Vol %1$s Chapter %2$s</string>
<string name="chapter_number">Chapter %s</string> <string name="chapter_number">Chapter %s</string>
<string name="unnamed_chapter">Unnamed chapter</string> <string name="unnamed_chapter">Unnamed chapter</string>
<string name="search_disabled_sources">Search through disabled sources</string>
</resources> </resources>

View File

@@ -31,7 +31,7 @@ material = "1.13.0-alpha11"
moshi = "1.15.2" moshi = "1.15.2"
okhttp = "4.12.0" okhttp = "4.12.0"
okio = "3.10.2" okio = "3.10.2"
parsers = "531145c7f9" parsers = "77a5216ebf"
preference = "1.2.1" preference = "1.2.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
room = "2.6.1" room = "2.6.1"