Improve global search
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user