Advanced global search

This commit is contained in:
Koitharu
2025-02-17 17:19:58 +02:00
parent 4ee52e149e
commit 74900970e1
33 changed files with 417 additions and 203 deletions

View File

@@ -10,25 +10,22 @@ import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
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.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.almostEquals
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.SearchV2Helper
import javax.inject.Inject import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor( class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository, private val sourcesRepository: MangaSourcesRepository,
private val searchHelperFactory: SearchV2Helper.Factory,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT) suspend operator fun invoke(manga: Manga): Flow<Manga> {
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
val sources = getSources(manga.source) val sources = getSources(manga.source)
if (sources.isEmpty()) { if (sources.isEmpty()) {
return emptyFlow() return emptyFlow()
@@ -36,21 +33,14 @@ class AlternativesUseCase @Inject constructor(
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow { return channelFlow {
for (source in sources) { for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch { launch {
val searchHelper = searchHelperFactory.create(source)
val list = runCatchingCancellable { val list = runCatchingCancellable {
semaphore.withPermit { semaphore.withPermit {
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) searchHelper(manga.title, SearchKind.TITLE)?.manga
} }
}.getOrDefault(emptyList()) }.getOrNull()
for (item in list) { list?.forEach { send(it) }
if (item.matches(manga, matchThreshold)) {
send(item)
}
}
} }
} }
}.map { }.map {
@@ -68,18 +58,6 @@ class AlternativesUseCase @Inject constructor(
return result return result
} }
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
return matchesTitles(title, ref.title, threshold) ||
matchesTitles(title, ref.altTitle, threshold) ||
matchesTitles(altTitle, ref.title, threshold) ||
matchesTitles(altTitle, ref.altTitle, threshold)
}
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (this is MangaParserSource && ref is MangaParserSource) { if (this is MangaParserSource && ref is MangaParserSource) {

View File

@@ -35,7 +35,7 @@ class AutoFixUseCase @Inject constructor(
if (seed.isHealthy()) { if (seed.isHealthy()) {
return seed to null // no fix required return seed to null // no fix required
} }
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f) val replacement = alternativesUseCase(seed)
.filter { it.isHealthy() } .filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate -> .runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) { if (best == null || best < candidate) {

View File

@@ -4,12 +4,14 @@ import android.accounts.Account
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.annotation.UiContext
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@@ -17,7 +19,9 @@ import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.findFragment import androidx.fragment.app.findFragment
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
@@ -25,13 +29,19 @@ import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
@@ -61,6 +71,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
@@ -68,6 +79,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
@@ -94,22 +106,27 @@ class AppRouter private constructor(
constructor(fragment: Fragment) : this(null, fragment) constructor(fragment: Fragment) : this(null, fragment)
/** Activities **/ private val settings: AppSettings by lazy {
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
fun openList(source: MangaSource, filter: MangaListFilter?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter))
} }
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag))) /** Activities **/
fun openSearch(query: String) { fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
}
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
startActivity( startActivity(
Intent(contextOrNull() ?: return, SearchActivity::class.java) Intent(contextOrNull() ?: return, SearchActivity::class.java)
.putExtra(KEY_QUERY, query), .putExtra(KEY_QUERY, query)
.putExtra(KEY_KIND, kind),
) )
} }
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query)) fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
fun openDetails(manga: Manga) { fun openDetails(manga: Manga) {
startActivity(detailsIntent(contextOrNull() ?: return, manga)) startActivity(detailsIntent(contextOrNull() ?: return, manga))
@@ -119,6 +136,13 @@ class AppRouter private constructor(
startActivity(detailsIntent(contextOrNull() ?: return, mangaId)) startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
} }
fun openDetails(link: Uri) {
startActivity(
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
.setData(link),
)
}
fun openReader(manga: Manga, anchor: View? = null) { fun openReader(manga: Manga, anchor: View? = null) {
openReader( openReader(
ReaderIntent.Builder(contextOrNull() ?: return) ReaderIntent.Builder(contextOrNull() ?: return)
@@ -327,6 +351,25 @@ class AppRouter private constructor(
}.showDistinct() }.showDistinct()
} }
fun showTagDialog(tag: MangaTag) {
buildAlertDialog(contextOrNull() ?: return) {
setTitle(tag.title)
setItems(
arrayOf(
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
context.getString(R.string.search_everywhere),
),
) { _, which ->
when (which) {
0 -> openList(tag)
1 -> openSearch(tag.title, SearchKind.TAG)
}
}
setNegativeButton(R.string.close, null)
setCancelable(true)
}.show()
}
fun showErrorDialog(error: Throwable, url: String? = null) { fun showErrorDialog(error: Throwable, url: String? = null) {
ErrorDetailsDialog().withArgs(2) { ErrorDetailsDialog().withArgs(2) {
putSerializable(KEY_ERROR, error) putSerializable(KEY_ERROR, error)
@@ -414,6 +457,45 @@ class AppRouter private constructor(
TrackerCategoriesConfigSheet().showDistinct() TrackerCategoriesConfigSheet().showDistinct()
} }
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
val context = contextOrNull() ?: return
when (settings.allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!context.connectivityManager.isActiveNetworkMetered) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
/** Public utils **/ /** Public utils **/
fun isFilterSupported(): Boolean = when { fun isFilterSupported(): Boolean = when {
@@ -462,6 +544,7 @@ class AppRouter private constructor(
return fragment?.childFragmentManager ?: activity?.supportFragmentManager return fragment?.childFragmentManager ?: activity?.supportFragmentManager
} }
@UiContext
private fun contextOrNull(): Context? = activity ?: fragment?.context private fun contextOrNull(): Context? = activity ?: fragment?.context
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
@@ -510,7 +593,7 @@ class AppRouter private constructor(
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java) fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_ID, mangaId) .putExtra(KEY_ID, mangaId)
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent = fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
Intent(context, MangaListActivity::class.java) Intent(context, MangaListActivity::class.java)
.setAction(ACTION_MANGA_EXPLORE) .setAction(ACTION_MANGA_EXPLORE)
.putExtra(KEY_SOURCE, source.name) .putExtra(KEY_SOURCE, source.name)
@@ -518,6 +601,9 @@ class AppRouter private constructor(
if (!filter.isNullOrEmpty()) { if (!filter.isNullOrEmpty()) {
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
} }
if (sortOrder != null) {
putExtra(KEY_SORT_ORDER, sortOrder)
}
} }
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
@@ -590,12 +676,14 @@ class AppRouter private constructor(
const val KEY_FILTER = "filter" const val KEY_FILTER = "filter"
const val KEY_ID = "id" const val KEY_ID = "id"
const val KEY_INDEX = "index" const val KEY_INDEX = "index"
const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section" const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list" const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages" const val KEY_PAGES = "pages"
const val KEY_QUERY = "query" const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE = "reader_mode"
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SOURCE = "source" const val KEY_SOURCE = "source"
const val KEY_TAB = "tab" const val KEY_TAB = "tab"
const val KEY_TITLE = "title" const val KEY_TITLE = "title"

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.nav
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppRouterEntryPoint {
val settings: AppSettings
}

View File

@@ -182,7 +182,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title) .setLongLabel(title)
.setIcon(icon) .setIcon(icon)
.setLongLived(true) .setLongLived(true)
.setIntent(AppRouter.listIntent(context, source, null)) .setIntent(AppRouter.listIntent(context, source, null, null))
.build() .build()
} }
} }

View File

@@ -6,6 +6,7 @@ import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor(
chapters = null, chapters = null,
source = source, source = source,
) )
companion object {
fun isValidLink(str: String): Boolean {
return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true)
}
}
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.CompoundButton.OnCheckedChangeListener
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
inline fun buildAlertDialog( inline fun buildAlertDialog(
context: Context, @UiContext context: Context,
isCentered: Boolean = false, isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit, block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder( ): AlertDialog = MaterialAlertDialogBuilder(

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.UiContext
import androidx.core.net.ConnectivityManagerCompat
import dagger.Lazy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import javax.inject.Inject
class CommonAlertDialogs @Inject constructor(
private val settings: Lazy<AppSettings>,
) {
fun askForDownloadOverMeteredNetwork(
@UiContext context: Context,
onConfirmed: (allow: Boolean) -> Unit
) {
when (settings.get().allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.content.Context import android.content.Context
import android.database.DatabaseUtils
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
@@ -70,11 +69,4 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
} }
} }
@Deprecated( fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true)
"",
ReplaceWith(
"sqlEscapeString(this)",
"android.database.DatabaseUtils.sqlEscapeString",
),
)
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)

View File

@@ -22,12 +22,12 @@ fun Uri.isNetworkUri() = scheme.let {
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
} }
fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath") fun File.toZipUri(entryPath: String): Uri = "$URI_SCHEME_ZIP://$absolutePath#$entryPath".toUri()
fun File.toZipUri(entryPath: Path?): Uri = fun File.toZipUri(entryPath: Path?): Uri =
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty()) toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else this.toUri()
fun File.toUri(fragment: String?): Uri = toUri().run { fun File.toUri(fragment: String?): Uri = toUri().run {
if (fragment != null) { if (fragment != null) {

View File

@@ -209,7 +209,7 @@ class DetailsActivity :
R.id.textView_source -> { R.id.textView_source -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
router.openList(manga.source, null) router.openList(manga.source, null, null)
} }
R.id.textView_local -> { R.id.textView_local -> {
@@ -255,8 +255,7 @@ class DetailsActivity :
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return val tag = data as? MangaTag ?: return
// TODO dialog router.showTagDialog(tag)
router.openList(tag)
} }
override fun onContextClick(v: View): Boolean = onLongClick(v) override fun onContextClick(v: View): Boolean = onLongClick(v)

View File

@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.dismissParentDialog import org.koitharu.kotatsu.core.nav.dismissParentDialog
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
@@ -39,7 +38,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
@@ -49,9 +47,6 @@ class ChaptersFragment :
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
@Inject
lateinit var commonAlertDialogs: CommonAlertDialogs
private var chaptersAdapter: ChaptersAdapter? = null private var chaptersAdapter: ChaptersAdapter? = null
private var selectionController: ListSelectionController? = null private var selectionController: ListSelectionController? = null
@@ -67,7 +62,7 @@ class ChaptersFragment :
appCompatDelegate = checkNotNull(findAppCompatDelegate()), appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = ChaptersSelectionDecoration(binding.root.context), decoration = ChaptersSelectionDecoration(binding.root.context),
registryOwner = this, registryOwner = this,
callback = ChaptersSelectionCallback(viewModel, commonAlertDialogs, binding.recyclerViewChapters), callback = ChaptersSelectionCallback(viewModel, router, binding.recyclerViewChapters),
) )
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {

View File

@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.toCollection import org.koitharu.kotatsu.core.util.ext.toCollection
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
class ChaptersSelectionCallback( class ChaptersSelectionCallback(
private val viewModel: ChaptersPagesViewModel, private val viewModel: ChaptersPagesViewModel,
private val commonAlertDialogs: CommonAlertDialogs, private val router: AppRouter,
recyclerView: RecyclerView, recyclerView: RecyclerView,
) : BaseListSelectionCallback(recyclerView) { ) : BaseListSelectionCallback(recyclerView) {
@@ -63,10 +63,9 @@ class ChaptersSelectionCallback(
val snapshot = controller.snapshot() val snapshot = controller.snapshot()
mode?.finish() mode?.finish()
if (snapshot.isNotEmpty()) { if (snapshot.isNotEmpty()) {
commonAlertDialogs.askForDownloadOverMeteredNetwork( router.askForDownloadOverMeteredNetwork {
context = recyclerView.context, viewModel.download(snapshot, it)
onConfirmed = { viewModel.download(snapshot, it) }, }
)
} }
true true
} }

View File

@@ -18,9 +18,9 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
@@ -32,9 +32,7 @@ import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.DialogDownloadBinding import org.koitharu.kotatsu.databinding.DialogDownloadBinding
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.settings.storage.DirectoryModel import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), View.OnClickListener { class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), View.OnClickListener {
@@ -42,9 +40,6 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
private val viewModel by viewModels<DownloadDialogViewModel>() private val viewModel by viewModels<DownloadDialogViewModel>()
private var optionViews: Array<out TwoLinesItemView>? = null private var optionViews: Array<out TwoLinesItemView>? = null
@Inject
lateinit var commonAlertDialogs: CommonAlertDialogs
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) = override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
DialogDownloadBinding.inflate(inflater, container, false) DialogDownloadBinding.inflate(inflater, container, false)
@@ -104,10 +99,7 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_cancel -> dialog?.cancel() R.id.button_cancel -> dialog?.cancel()
R.id.button_confirm -> commonAlertDialogs.askForDownloadOverMeteredNetwork( R.id.button_confirm -> router.askForDownloadOverMeteredNetwork(::schedule)
context = context ?: return,
onConfirmed = ::schedule,
)
R.id.textView_more -> { R.id.textView_more -> {
val binding = viewBinding ?: return val binding = viewBinding ?: return

View File

@@ -268,7 +268,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
if (manga != null) { if (manga != null) {
AppRouter.detailsIntent(context, manga) AppRouter.detailsIntent(context, manga)
} else { } else {
AppRouter.listIntent(context, LocalMangaSource, null) AppRouter.listIntent(context, LocalMangaSource, null, null)
}, },
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false, false,

View File

@@ -121,7 +121,7 @@ class ExploreFragment :
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_local -> router.openList(LocalMangaSource, null) R.id.button_local -> router.openList(LocalMangaSource, null, null)
R.id.button_bookmarks -> router.openBookmarks() R.id.button_bookmarks -> router.openBookmarks()
R.id.button_more -> router.openSuggestions() R.id.button_more -> router.openSuggestions()
R.id.button_downloads -> router.openDownloads() R.id.button_downloads -> router.openDownloads()
@@ -133,7 +133,7 @@ class ExploreFragment :
if (sourceSelectionController?.onItemClick(item.id) == true) { if (sourceSelectionController?.onItemClick(item.id) == true) {
return return
} }
router.openList(item.source, null) router.openList(item.source, null, null)
} }
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {

View File

@@ -15,6 +15,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.SoftwareKeyboardControllerCompat import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
@@ -41,6 +42,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.parser.MangaLinkResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -61,6 +63,7 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
@@ -246,11 +249,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
router.openDetails(manga) router.openDetails(manga)
} }
override fun onQueryClick(query: String, submit: Boolean) { override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) {
viewBinding.searchView.query = query viewBinding.searchView.query = query
if (submit && query.isNotEmpty()) { if (submit && query.isNotEmpty()) {
router.openSearch(query) if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) {
searchSuggestionViewModel.saveQuery(query) router.openDetails(query.toUri())
} else {
router.openSearch(query, kind)
if (kind != SearchKind.TAG) {
searchSuggestionViewModel.saveQuery(query)
}
}
viewBinding.searchView.post { viewBinding.searchView.post {
closeSearchCallback.handleOnBackPressed() closeSearchCallback.handleOnBackPressed()
} }
@@ -258,7 +267,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
override fun onTagClick(tag: MangaTag) { override fun onTagClick(tag: MangaTag) {
router.openList(tag) router.openSearch(tag.title, SearchKind.TAG)
} }
override fun onQueryChanged(query: String) { override fun onQueryChanged(query: String) {
@@ -270,7 +279,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
override fun onSourceClick(source: MangaSource) { override fun onSourceClick(source: MangaSource) {
router.openList(source, null) router.openList(source, null, null)
} }
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.search.domain
enum class SearchKind {
SIMPLE, TITLE, AUTHOR, TAG
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.search.domain
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.SortOrder
data class SearchResults(
val listFilter: MangaListFilter,
val sortOrder: SortOrder,
val manga: List<Manga>,
)

View File

@@ -0,0 +1,124 @@
package org.koitharu.kotatsu.search.domain
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class SearchV2Helper @AssistedInject constructor(
@Assisted private val source: MangaSource,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val dataRepository: MangaDataRepository,
) {
suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? {
val repository = mangaRepositoryFactory.create(source)
val listFilter = repository.getFilter(query, kind) ?: return null
val sortOrder = repository.getSortOrder(kind)
val list = repository.getList(0, sortOrder, listFilter)
if (list.isEmpty()) {
return null
}
val result = list.toMutableList()
result.postFilter(query, kind)
result.sortByRelevance(query, kind)
return SearchResults(listFilter = listFilter, sortOrder = sortOrder, manga = result)
}
private suspend fun MangaRepository.getFilter(query: String, kind: SearchKind): MangaListFilter? = when (kind) {
SearchKind.SIMPLE,
SearchKind.TITLE,
SearchKind.AUTHOR -> if (filterCapabilities.isSearchSupported) { // TODO author support
MangaListFilter(query = query)
} else {
null
}
SearchKind.TAG -> {
val tags = this@SearchV2Helper.dataRepository.findTags(this.source) + runCatchingCancellable {
this@getFilter.getFilterOptions().availableTags
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrDefault(emptySet())
val tag = tags.find { x -> x.title.equals(query, ignoreCase = true) }
if (tag != null) {
MangaListFilter(tags = setOf(tag))
} else {
null
}
}
}
private fun MutableList<Manga>.postFilter(query: String, kind: SearchKind) {
when (kind) {
SearchKind.TITLE -> retainAll { m ->
m.matches(query, MATCH_THRESHOLD_DEFAULT)
}
SearchKind.AUTHOR -> retainAll { m ->
m.author.isNullOrEmpty() || m.author.equals(query, ignoreCase = true)
}
SearchKind.SIMPLE, // no filtering expected
SearchKind.TAG -> Unit
}
}
private fun MutableList<Manga>.sortByRelevance(query: String, kind: SearchKind) {
when (kind) {
SearchKind.SIMPLE,
SearchKind.TITLE -> sortBy { m ->
minOf(m.title.levenshteinDistance(query), m.altTitle?.levenshteinDistance(query) ?: Int.MAX_VALUE)
}
SearchKind.AUTHOR -> sortByDescending { m ->
m.author?.equals(query, ignoreCase = true) == true
}
SearchKind.TAG -> sortByDescending { m ->
m.tags.any { tag -> tag.title.equals(query, ignoreCase = true) }
}
}
}
private fun MangaRepository.getSortOrder(kind: SearchKind): SortOrder {
val preferred: SortOrder = when (kind) {
SearchKind.SIMPLE,
SearchKind.TITLE,
SearchKind.AUTHOR -> SortOrder.RELEVANCE
SearchKind.TAG -> SortOrder.POPULARITY
}
return if (preferred in sortOrders) {
preferred
} else {
defaultSortOrder
}
}
private fun Manga.matches(query: String, threshold: Float): Boolean {
return matchesTitles(title, query, threshold) || matchesTitles(altTitle, query, threshold)
}
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
@AssistedFactory
interface Factory {
fun create(source: MangaSource): SearchV2Helper
}
}

View File

@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ViewBadge import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
@@ -44,6 +45,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
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
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -67,6 +69,7 @@ class MangaListActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityMangaListBinding.inflate(layoutInflater)) setContentView(ActivityMangaListBinding.inflate(layoutInflater))
val filter = intent.getParcelableExtraCompat<ParcelableMangaListFilter>(AppRouter.KEY_FILTER)?.filter val filter = intent.getParcelableExtraCompat<ParcelableMangaListFilter>(AppRouter.KEY_FILTER)?.filter
val sortOrder = intent.getSerializableExtraCompat<SortOrder>(AppRouter.KEY_SORT_ORDER)
source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)) source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (viewBinding.containerFilterHeader != null) { if (viewBinding.containerFilterHeader != null) {
@@ -74,7 +77,7 @@ class MangaListActivity :
} }
viewBinding.buttonOrder?.setOnClickListener(this) viewBinding.buttonOrder?.setOnClickListener(this)
title = source.getTitle(this) title = source.getTitle(this)
initList(source, filter) initList(source, filter, sortOrder)
} }
override fun isNsfwContent(): Flow<Boolean> = flowOf(source.isNsfw()) override fun isNsfwContent(): Flow<Boolean> = flowOf(source.isNsfw())
@@ -112,7 +115,7 @@ class MangaListActivity :
fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null)
private fun initList(source: MangaSource, filter: MangaListFilter?) { private fun initList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
val fm = supportFragmentManager val fm = supportFragmentManager
val existingFragment = fm.findFragmentById(R.id.container) val existingFragment = fm.findFragmentById(R.id.container)
if (existingFragment is FilterCoordinator.Owner) { if (existingFragment is FilterCoordinator.Owner) {
@@ -127,8 +130,8 @@ class MangaListActivity :
} }
replace(R.id.container, fragment) replace(R.id.container, fragment)
runOnCommit { initFilter(fragment) } runOnCommit { initFilter(fragment) }
if (filter != null) { if (filter != null || sortOrder != null) {
runOnCommit(ApplyFilterRunnable(fragment, filter)) runOnCommit(ApplyFilterRunnable(fragment, filter, sortOrder))
} }
} }
} }
@@ -182,11 +185,17 @@ class MangaListActivity :
private class ApplyFilterRunnable( private class ApplyFilterRunnable(
private val filterOwner: FilterCoordinator.Owner, private val filterOwner: FilterCoordinator.Owner,
private val filter: MangaListFilter, private val filter: MangaListFilter?,
private val sortOrder: SortOrder?,
) : Runnable { ) : Runnable {
override fun run() { override fun run() {
filterOwner.filterCoordinator.set(filter) if (sortOrder != null) {
filterOwner.filterCoordinator.setSortOrder(sortOrder)
}
if (filter != null) {
filterOwner.filterCoordinator.set(filter)
}
} }
} }
} }

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter
import javax.inject.Inject import javax.inject.Inject
@@ -53,10 +54,25 @@ class SearchActivity :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivitySearchBinding.inflate(layoutInflater)) setContentView(ActivitySearchBinding.inflate(layoutInflater))
title = viewModel.query title = when (viewModel.kind) {
SearchKind.SIMPLE,
SearchKind.TITLE -> viewModel.query
SearchKind.AUTHOR -> getString(
R.string.inline_preference_pattern,
getString(R.string.author),
viewModel.query,
)
SearchKind.TAG -> getString(R.string.inline_preference_pattern, getString(R.string.genre), viewModel.query)
}
val itemClickListener = OnListItemClickListener<SearchResultsListModel> { item, view -> val itemClickListener = OnListItemClickListener<SearchResultsListModel> { item, view ->
router.openSearch(item.source, viewModel.query) if (item.listFilter == null) {
router.openSearch(item.source, viewModel.query)
} else {
router.openList(item.source, item.listFilter, item.sortOrder)
}
} }
val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true)
val selectionDecoration = MangaSelectionDecoration(this) val selectionDecoration = MangaSelectionDecoration(this)

View File

@@ -6,11 +6,15 @@ import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
data class SearchResultsListModel( data class SearchResultsListModel(
@StringRes val titleResId: Int, @StringRes val titleResId: Int,
val source: MangaSource, val source: MangaSource,
val listFilter: MangaListFilter?,
val sortOrder: SortOrder?,
val hasMore: Boolean, val hasMore: Boolean,
val list: List<MangaListModel>, val list: List<MangaListModel>,
val error: Throwable?, val error: Throwable?,

View File

@@ -26,7 +26,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
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
@@ -43,6 +42,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
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.SearchV2Helper
import javax.inject.Inject import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@@ -52,7 +53,7 @@ private const val MIN_HAS_MORE_ITEMS = 8
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val mangaRepositoryFactory: MangaRepository.Factory, private val searchHelperFactory: SearchV2Helper.Factory,
private val sourcesRepository: MangaSourcesRepository, private val sourcesRepository: MangaSourcesRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
@@ -60,6 +61,7 @@ class SearchViewModel @Inject constructor(
) : BaseViewModel() { ) : BaseViewModel() {
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
private val retryCounter = MutableStateFlow(0) private val retryCounter = MutableStateFlow(0)
private val listData = retryCounter.flatMapLatest { private val listData = retryCounter.flatMapLatest {
@@ -115,35 +117,40 @@ class SearchViewModel @Inject constructor(
return@channelFlow return@channelFlow
} }
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
sources.mapNotNull { source -> sources.map { source ->
val repository = mangaRepositoryFactory.create(source) launch {
if (!repository.filterCapabilities.isSearchSupported) { val item = runCatchingCancellable {
null semaphore.withPermit {
} else { val searchHelper = searchHelperFactory.create(source)
launch { searchHelper(query, kind)
val item = runCatchingCancellable { }
semaphore.withPermit { }.fold(
mangaListMapper.toListModelList( onSuccess = { result ->
manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), if (result == null || result.manga.isEmpty()) {
null
} else {
val list = mangaListMapper.toListModelList(
manga = result.manga,
mode = ListMode.GRID, mode = ListMode.GRID,
) )
SearchResultsListModel(
titleResId = 0,
source = source,
hasMore = list.size > MIN_HAS_MORE_ITEMS,
list = list,
error = null,
listFilter = result.listFilter,
sortOrder = result.sortOrder,
)
} }
}.fold( },
onSuccess = { list -> onFailure = { error ->
if (list.isEmpty()) { error.printStackTraceDebug()
null SearchResultsListModel(0, source, null, null, true, emptyList(), error)
} else { },
SearchResultsListModel(0, source, list.size > MIN_HAS_MORE_ITEMS, list, null) )
} if (item != null) {
}, send(item)
onFailure = { error ->
error.printStackTraceDebug()
SearchResultsListModel(0, source, true, emptyList(), error)
},
)
if (item != null) {
send(item)
}
} }
} }
}.joinAll() }.joinAll()
@@ -163,6 +170,8 @@ class SearchViewModel @Inject constructor(
hasMore = false, hasMore = false,
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
error = null, error = null,
listFilter = null,
sortOrder = null,
) )
} else { } else {
null null
@@ -175,6 +184,8 @@ class SearchViewModel @Inject constructor(
hasMore = false, hasMore = false,
list = emptyList(), list = emptyList(),
error = error, error = error,
listFilter = null,
sortOrder = null,
) )
}, },
) )
@@ -192,6 +203,8 @@ class SearchViewModel @Inject constructor(
hasMore = false, hasMore = false,
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
error = null, error = null,
listFilter = null,
sortOrder = null,
) )
} else { } else {
null null
@@ -204,6 +217,8 @@ class SearchViewModel @Inject constructor(
hasMore = false, hasMore = false,
list = emptyList(), list = emptyList(),
error = error, error = error,
listFilter = null,
sortOrder = null,
) )
}, },
) )
@@ -221,6 +236,8 @@ class SearchViewModel @Inject constructor(
hasMore = result.size > MIN_HAS_MORE_ITEMS, hasMore = result.size > MIN_HAS_MORE_ITEMS,
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
error = null, error = null,
listFilter = null,
sortOrder = null,
) )
} else { } else {
null null
@@ -233,6 +250,8 @@ class SearchViewModel @Inject constructor(
hasMore = true, hasMore = true,
list = emptyList(), list = emptyList(),
error = error, error = error,
listFilter = null,
sortOrder = null,
) )
}, },
) )

View File

@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.search.ui.suggestion
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.SearchKind
interface SearchSuggestionListener { interface SearchSuggestionListener {
fun onMangaClick(manga: Manga) fun onMangaClick(manga: Manga)
fun onQueryClick(query: String, submit: Boolean) fun onQueryClick(query: String, kind: SearchKind, submit: Boolean)
fun onQueryChanged(query: String) fun onQueryChanged(query: String)

View File

@@ -4,6 +4,7 @@ import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
@@ -14,7 +15,7 @@ fun searchSuggestionAuthorAD(
) { ) {
val viewClickListener = View.OnClickListener { _ -> val viewClickListener = View.OnClickListener { _ ->
listener.onQueryClick(item.name, true) listener.onQueryClick(item.name, SearchKind.AUTHOR, true)
} }
binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_user, 0, 0, 0) binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_user, 0, 0, 0)

View File

@@ -4,23 +4,25 @@ import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
fun searchSuggestionQueryAD( fun searchSuggestionQueryAD(
listener: SearchSuggestionListener, listener: SearchSuggestionListener,
) = adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>( ) =
{ inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) } adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>(
) { { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) },
) {
val viewClickListener = View.OnClickListener { v -> val viewClickListener = View.OnClickListener { v ->
listener.onQueryClick(item.query, v.id != R.id.button_complete) listener.onQueryClick(item.query, SearchKind.SIMPLE, v.id != R.id.button_complete)
}
binding.root.setOnClickListener(viewClickListener)
binding.buttonComplete.setOnClickListener(viewClickListener)
bind {
binding.textViewTitle.text = item.query
}
} }
binding.root.setOnClickListener(viewClickListener)
binding.buttonComplete.setOnClickListener(viewClickListener)
bind {
binding.textViewTitle.text = item.query
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter
import android.view.View import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
@@ -13,7 +14,7 @@ fun searchSuggestionQueryHintAD(
) { ) {
val viewClickListener = View.OnClickListener { _ -> val viewClickListener = View.OnClickListener { _ ->
listener.onQueryClick(item.query, true) listener.onQueryClick(item.query, SearchKind.SIMPLE, true)
} }
binding.root.setOnClickListener(viewClickListener) binding.root.setOnClickListener(viewClickListener)

View File

@@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -66,7 +67,7 @@ class SearchEditText @JvmOverloads constructor(
&& query.isNotEmpty() && query.isNotEmpty()
) { ) {
cancelLongPress() cancelLongPress()
searchSuggestionListener?.onQueryClick(query, submit = true) searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
clearFocus() clearFocus()
return true return true
} }
@@ -76,7 +77,7 @@ class SearchEditText @JvmOverloads constructor(
override fun onEditorAction(actionCode: Int) { override fun onEditorAction(actionCode: Int) {
super.onEditorAction(actionCode) super.onEditorAction(actionCode)
if (actionCode == EditorInfo.IME_ACTION_SEARCH) { if (actionCode == EditorInfo.IME_ACTION_SEARCH) {
searchSuggestionListener?.onQueryClick(query, submit = true) searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
} }
} }

View File

@@ -89,7 +89,7 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
} }
override fun onItemClick(item: SourceCatalogItem.Source, view: View) { override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
router.openList(item.source, null) router.openList(item.source, null, null)
} }
override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean {

View File

@@ -6,6 +6,7 @@ import android.content.Context
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import javax.inject.Inject import javax.inject.Inject
@@ -39,10 +40,10 @@ class SyncSettings(
companion object { companion object {
private fun String.withHttpSchema(): String = if (!startsWith("http://") && !startsWith("https://")) { private fun String.withHttpSchema(): String = if (isHttpUrl()) {
"http://$this"
} else {
this this
} else {
"http://$this"
} }
const val KEY_SYNC_URL = "host" const val KEY_SYNC_URL = "host"

View File

@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
@@ -66,7 +67,7 @@ class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletet
DialogInterface.BUTTON_POSITIVE -> { DialogInterface.BUTTON_POSITIVE -> {
val result = requireViewBinding().edit.text?.toString().orEmpty() val result = requireViewBinding().edit.text?.toString().orEmpty()
var scheme = "" var scheme = ""
if (!result.startsWith("https://") && !result.startsWith("http://")) { if (!result.isHttpUrl()) {
scheme = "http://" scheme = "http://"
} }
syncSettings.syncUrl = "$scheme$result" syncSettings.syncUrl = "$scheme$result"

View File

@@ -801,4 +801,5 @@
<string name="screen_rotation_locked">Screen rotation has been locked</string> <string name="screen_rotation_locked">Screen rotation has been locked</string>
<string name="screen_rotation_unlocked">Screen rotation has been unlocked</string> <string name="screen_rotation_unlocked">Screen rotation has been unlocked</string>
<string name="badges_in_lists">Badges in lists</string> <string name="badges_in_lists">Badges in lists</string>
<string name="search_everywhere">Search everywhere</string>
</resources> </resources>