diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt index 969e61c4c..95a80d272 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AlternativesUseCase.kt @@ -10,25 +10,22 @@ import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository 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.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.search.domain.SearchKind +import org.koitharu.kotatsu.search.domain.SearchV2Helper import javax.inject.Inject private const val MAX_PARALLELISM = 4 -private const val MATCH_THRESHOLD_DEFAULT = 0.2f class AlternativesUseCase @Inject constructor( private val sourcesRepository: MangaSourcesRepository, + private val searchHelperFactory: SearchV2Helper.Factory, private val mangaRepositoryFactory: MangaRepository.Factory, ) { - suspend operator fun invoke(manga: Manga): Flow = invoke(manga, MATCH_THRESHOLD_DEFAULT) - - suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow { + suspend operator fun invoke(manga: Manga): Flow { val sources = getSources(manga.source) if (sources.isEmpty()) { return emptyFlow() @@ -36,21 +33,14 @@ class AlternativesUseCase @Inject constructor( val semaphore = Semaphore(MAX_PARALLELISM) return channelFlow { for (source in sources) { - val repository = mangaRepositoryFactory.create(source) - if (!repository.filterCapabilities.isSearchSupported) { - continue - } launch { + val searchHelper = searchHelperFactory.create(source) val list = runCatchingCancellable { semaphore.withPermit { - repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) + searchHelper(manga.title, SearchKind.TITLE)?.manga } - }.getOrDefault(emptyList()) - for (item in list) { - if (item.matches(manga, matchThreshold)) { - send(item) - } - } + }.getOrNull() + list?.forEach { send(it) } } } }.map { @@ -68,18 +58,6 @@ class AlternativesUseCase @Inject constructor( 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 { var res = 0 if (this is MangaParserSource && ref is MangaParserSource) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt index 15ec1622c..96b5866b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt @@ -35,7 +35,7 @@ class AutoFixUseCase @Inject constructor( if (seed.isHealthy()) { return seed to null // no fix required } - val replacement = alternativesUseCase(seed, matchThreshold = 0.02f) + val replacement = alternativesUseCase(seed) .filter { it.isHealthy() } .runningFold(null) { best, candidate -> if (best == null || best < candidate) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt index b31b7708e..e77e3d884 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -4,12 +4,14 @@ import android.accounts.Account import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context +import android.content.DialogInterface import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.View import androidx.annotation.CheckResult +import androidx.annotation.UiContext import androidx.core.net.toUri import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment @@ -17,7 +19,9 @@ import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.findFragment import androidx.lifecycle.LifecycleOwner +import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity 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.model.FavouriteCategory 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.ParcelableMangaListFilter import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.network.CommonHeaders 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.TriStateOption +import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog 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.toUriOrNull 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.MangaSource 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.mapToArray 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.ui.config.ScrobblerConfigActivity 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.multi.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity @@ -94,22 +106,27 @@ class AppRouter private constructor( constructor(fragment: Fragment) : this(null, fragment) + private val settings: AppSettings by lazy { + EntryPointAccessors.fromApplication(checkNotNull(contextOrNull())).settings + } + /** Activities **/ - fun openList(source: MangaSource, filter: MangaListFilter?) { - startActivity(listIntent(contextOrNull() ?: return, source, filter)) + 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))) + fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null) - fun openSearch(query: String) { + fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) { startActivity( 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) { startActivity(detailsIntent(contextOrNull() ?: return, manga)) @@ -119,6 +136,13 @@ class AppRouter private constructor( 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) { openReader( ReaderIntent.Builder(contextOrNull() ?: return) @@ -327,6 +351,25 @@ class AppRouter private constructor( }.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) { ErrorDetailsDialog().withArgs(2) { putSerializable(KEY_ERROR, error) @@ -414,6 +457,45 @@ class AppRouter private constructor( 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 **/ fun isFilterSupported(): Boolean = when { @@ -462,6 +544,7 @@ class AppRouter private constructor( return fragment?.childFragmentManager ?: activity?.supportFragmentManager } + @UiContext private fun contextOrNull(): Context? = activity ?: fragment?.context 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) .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) .setAction(ACTION_MANGA_EXPLORE) .putExtra(KEY_SOURCE, source.name) @@ -518,6 +601,9 @@ class AppRouter private constructor( if (!filter.isNullOrEmpty()) { putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) } + if (sortOrder != null) { + putExtra(KEY_SORT_ORDER, sortOrder) + } } fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = @@ -590,12 +676,14 @@ class AppRouter private constructor( const val KEY_FILTER = "filter" const val KEY_ID = "id" const val KEY_INDEX = "index" + const val KEY_KIND = "kind" const val KEY_LIST_SECTION = "list_section" const val KEY_MANGA = "manga" const val KEY_MANGA_LIST = "manga_list" const val KEY_PAGES = "pages" const val KEY_QUERY = "query" const val KEY_READER_MODE = "reader_mode" + const val KEY_SORT_ORDER = "sort_order" const val KEY_SOURCE = "source" const val KEY_TAB = "tab" const val KEY_TITLE = "title" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt new file mode 100644 index 000000000..619962e5b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt index ab44b1819..075102e05 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt @@ -182,7 +182,7 @@ class AppShortcutManager @Inject constructor( .setLongLabel(title) .setIcon(icon) .setLongLived(true) - .setIntent(AppRouter.listIntent(context, source, null)) + .setIntent(AppRouter.listIntent(context, source, null, null)) .build() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt index 7a94fcf2f..72fe0ba59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt @@ -6,6 +6,7 @@ import dagger.Reusable import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource 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.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga @@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor( chapters = null, source = source, ) + + companion object { + + fun isValidLink(str: String): Boolean { + return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt index bfdf6dde3..e18741e76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt @@ -4,6 +4,7 @@ import android.content.Context import android.view.LayoutInflater import android.widget.CompoundButton.OnCheckedChangeListener import androidx.annotation.StringRes +import androidx.annotation.UiContext import androidx.appcompat.app.AlertDialog import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager @@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import com.google.android.material.R as materialR inline fun buildAlertDialog( - context: Context, + @UiContext context: Context, isCentered: Boolean = false, block: MaterialAlertDialogBuilder.() -> Unit, ): AlertDialog = MaterialAlertDialogBuilder( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt deleted file mode 100644 index 3eaf94219..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt +++ /dev/null @@ -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, -) { - - 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() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 8e3acc264..6e5975d19 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context -import android.database.DatabaseUtils import androidx.collection.arraySetOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ellipsize @@ -70,11 +69,4 @@ fun Collection.joinToStringWithLimit(context: Context, limit: Int, transf } } -@Deprecated( - "", - ReplaceWith( - "sqlEscapeString(this)", - "android.database.DatabaseUtils.sqlEscapeString", - ), -) -fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this) +fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt index 4bd885bef..d4c5e5885 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt @@ -22,12 +22,12 @@ fun Uri.isNetworkUri() = scheme.let { 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 = 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 { if (fragment != null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 5d213fc82..4c774bd0a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -209,7 +209,7 @@ class DetailsActivity : R.id.textView_source -> { val manga = viewModel.manga.value ?: return - router.openList(manga.source, null) + router.openList(manga.source, null, null) } R.id.textView_local -> { @@ -255,8 +255,7 @@ class DetailsActivity : override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return - // TODO dialog - router.openList(tag) + router.showTagDialog(tag) } override fun onContextClick(v: View): Boolean = onLongClick(v) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index aefa857e8..32ed1edb3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.dismissParentDialog import org.koitharu.kotatsu.core.nav.router 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.OnListItemClickListener 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.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState -import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint @@ -49,9 +47,6 @@ class ChaptersFragment : private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) - @Inject - lateinit var commonAlertDialogs: CommonAlertDialogs - private var chaptersAdapter: ChaptersAdapter? = null private var selectionController: ListSelectionController? = null @@ -67,7 +62,7 @@ class ChaptersFragment : appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = ChaptersSelectionDecoration(binding.root.context), registryOwner = this, - callback = ChaptersSelectionCallback(viewModel, commonAlertDialogs, binding.recyclerViewChapters), + callback = ChaptersSelectionCallback(viewModel, router, binding.recyclerViewChapters), ) viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt index 4c4925d75..fea035a47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt @@ -8,7 +8,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R 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.ListSelectionController import org.koitharu.kotatsu.core.util.ext.toCollection @@ -18,7 +18,7 @@ import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService class ChaptersSelectionCallback( private val viewModel: ChaptersPagesViewModel, - private val commonAlertDialogs: CommonAlertDialogs, + private val router: AppRouter, recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { @@ -63,10 +63,9 @@ class ChaptersSelectionCallback( val snapshot = controller.snapshot() mode?.finish() if (snapshot.isNotEmpty()) { - commonAlertDialogs.askForDownloadOverMeteredNetwork( - context = recyclerView.context, - onConfirmed = { viewModel.download(snapshot, it) }, - ) + router.askForDownloadOverMeteredNetwork { + viewModel.download(snapshot, it) + } } true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt index fc2dccaf5..51d435bcd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt @@ -18,9 +18,9 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R 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.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView import org.koitharu.kotatsu.core.util.ext.findActivity 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.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.util.format -import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.settings.storage.DirectoryModel -import javax.inject.Inject @AndroidEntryPoint class DownloadDialogFragment : AlertDialogFragment(), View.OnClickListener { @@ -42,9 +40,6 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie private val viewModel by viewModels() private var optionViews: Array? = null - @Inject - lateinit var commonAlertDialogs: CommonAlertDialogs - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) = DialogDownloadBinding.inflate(inflater, container, false) @@ -104,10 +99,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> dialog?.cancel() - R.id.button_confirm -> commonAlertDialogs.askForDownloadOverMeteredNetwork( - context = context ?: return, - onConfirmed = ::schedule, - ) + R.id.button_confirm -> router.askForDownloadOverMeteredNetwork(::schedule) R.id.textView_more -> { val binding = viewBinding ?: return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 3cd30a1aa..f6bf39208 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -268,7 +268,7 @@ class DownloadNotificationFactory @AssistedInject constructor( if (manga != null) { AppRouter.detailsIntent(context, manga) } else { - AppRouter.listIntent(context, LocalMangaSource, null) + AppRouter.listIntent(context, LocalMangaSource, null, null) }, PendingIntent.FLAG_CANCEL_CURRENT, false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 34de65789..5df735f6e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -121,7 +121,7 @@ class ExploreFragment : override fun onClick(v: View) { 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_more -> router.openSuggestions() R.id.button_downloads -> router.openDownloads() @@ -133,7 +133,7 @@ class ExploreFragment : if (sourceSelectionController?.onItemClick(item.id) == true) { return } - router.openList(item.source, null) + router.openList(item.source, null, null) } override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 330e4a06c..a1f0d98e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.Insets +import androidx.core.net.toUri import androidx.core.view.SoftwareKeyboardControllerCompat import androidx.core.view.children import androidx.core.view.inputmethod.EditorInfoCompat @@ -41,6 +42,7 @@ import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver 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.NavItem 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.MangaSource 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.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel @@ -246,11 +249,17 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav router.openDetails(manga) } - override fun onQueryClick(query: String, submit: Boolean) { + override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) { viewBinding.searchView.query = query if (submit && query.isNotEmpty()) { - router.openSearch(query) - searchSuggestionViewModel.saveQuery(query) + if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) { + router.openDetails(query.toUri()) + } else { + router.openSearch(query, kind) + if (kind != SearchKind.TAG) { + searchSuggestionViewModel.saveQuery(query) + } + } viewBinding.searchView.post { closeSearchCallback.handleOnBackPressed() } @@ -258,7 +267,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onTagClick(tag: MangaTag) { - router.openList(tag) + router.openSearch(tag.title, SearchKind.TAG) } override fun onQueryChanged(query: String) { @@ -270,7 +279,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onSourceClick(source: MangaSource) { - router.openList(source, null) + router.openList(source, null, null) } override fun onSupportActionModeStarted(mode: ActionMode) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt new file mode 100644 index 000000000..33dfcd189 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.search.domain + +enum class SearchKind { + + SIMPLE, TITLE, AUTHOR, TAG +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt new file mode 100644 index 000000000..b4ebed44c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt new file mode 100644 index 000000000..b6676acb9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt @@ -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.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.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 + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index b7dd3a741..f2049dbdd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ViewBadge 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.observe 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.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import kotlin.math.absoluteValue import com.google.android.material.R as materialR @@ -67,6 +69,7 @@ class MangaListActivity : super.onCreate(savedInstanceState) setContentView(ActivityMangaListBinding.inflate(layoutInflater)) val filter = intent.getParcelableExtraCompat(AppRouter.KEY_FILTER)?.filter + val sortOrder = intent.getSerializableExtraCompat(AppRouter.KEY_SORT_ORDER) source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)) supportActionBar?.setDisplayHomeAsUpEnabled(true) if (viewBinding.containerFilterHeader != null) { @@ -74,7 +77,7 @@ class MangaListActivity : } viewBinding.buttonOrder?.setOnClickListener(this) title = source.getTitle(this) - initList(source, filter) + initList(source, filter, sortOrder) } override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) @@ -112,7 +115,7 @@ class MangaListActivity : 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 existingFragment = fm.findFragmentById(R.id.container) if (existingFragment is FilterCoordinator.Owner) { @@ -127,8 +130,8 @@ class MangaListActivity : } replace(R.id.container, fragment) runOnCommit { initFilter(fragment) } - if (filter != null) { - runOnCommit(ApplyFilterRunnable(fragment, filter)) + if (filter != null || sortOrder != null) { + runOnCommit(ApplyFilterRunnable(fragment, filter, sortOrder)) } } } @@ -182,11 +185,17 @@ class MangaListActivity : private class ApplyFilterRunnable( private val filterOwner: FilterCoordinator.Owner, - private val filter: MangaListFilter, + private val filter: MangaListFilter?, + private val sortOrder: SortOrder?, ) : Runnable { override fun run() { - filterOwner.filterCoordinator.set(filter) + if (sortOrder != null) { + filterOwner.filterCoordinator.setSortOrder(sortOrder) + } + if (filter != null) { + filterOwner.filterCoordinator.set(filter) + } } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 13b6e4460..3f3f0917d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import javax.inject.Inject @@ -53,10 +54,25 @@ class SearchActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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 { 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 selectionDecoration = MangaSelectionDecoration(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt index ef6a4d5ee..2baf21777 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt @@ -6,11 +6,15 @@ import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel 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.SortOrder data class SearchResultsListModel( @StringRes val titleResId: Int, val source: MangaSource, + val listFilter: MangaListFilter?, + val sortOrder: SortOrder?, val hasMore: Boolean, val list: List, val error: Throwable?, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index ac5cbef66..4ba42c203 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -26,7 +26,6 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource 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.ui.BaseViewModel 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.SortOrder 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 private const val MAX_PARALLELISM = 4 @@ -52,7 +53,7 @@ private const val MIN_HAS_MORE_ITEMS = 8 class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, - private val mangaRepositoryFactory: MangaRepository.Factory, + private val searchHelperFactory: SearchV2Helper.Factory, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val localMangaRepository: LocalMangaRepository, @@ -60,6 +61,7 @@ class SearchViewModel @Inject constructor( ) : BaseViewModel() { val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() + val kind = savedStateHandle.get(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE private val retryCounter = MutableStateFlow(0) private val listData = retryCounter.flatMapLatest { @@ -115,35 +117,40 @@ class SearchViewModel @Inject constructor( return@channelFlow } val semaphore = Semaphore(MAX_PARALLELISM) - sources.mapNotNull { source -> - val repository = mangaRepositoryFactory.create(source) - if (!repository.filterCapabilities.isSearchSupported) { - null - } else { - launch { - val item = runCatchingCancellable { - semaphore.withPermit { - mangaListMapper.toListModelList( - manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), + sources.map { source -> + launch { + val item = runCatchingCancellable { + semaphore.withPermit { + 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, + hasMore = list.size > MIN_HAS_MORE_ITEMS, + list = list, + error = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, + ) } - }.fold( - onSuccess = { list -> - if (list.isEmpty()) { - null - } else { - SearchResultsListModel(0, source, list.size > MIN_HAS_MORE_ITEMS, list, null) - } - }, - onFailure = { error -> - error.printStackTraceDebug() - SearchResultsListModel(0, source, true, emptyList(), error) - }, - ) - if (item != null) { - send(item) - } + }, + onFailure = { error -> + error.printStackTraceDebug() + SearchResultsListModel(0, source, null, null, true, emptyList(), error) + }, + ) + if (item != null) { + send(item) } } }.joinAll() @@ -163,6 +170,8 @@ class SearchViewModel @Inject constructor( hasMore = false, list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), error = null, + listFilter = null, + sortOrder = null, ) } else { null @@ -175,6 +184,8 @@ class SearchViewModel @Inject constructor( hasMore = false, list = emptyList(), error = error, + listFilter = null, + sortOrder = null, ) }, ) @@ -192,6 +203,8 @@ class SearchViewModel @Inject constructor( hasMore = false, list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), error = null, + listFilter = null, + sortOrder = null, ) } else { null @@ -204,6 +217,8 @@ class SearchViewModel @Inject constructor( hasMore = false, list = emptyList(), error = error, + listFilter = null, + sortOrder = null, ) }, ) @@ -221,6 +236,8 @@ class SearchViewModel @Inject constructor( hasMore = result.size > MIN_HAS_MORE_ITEMS, list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), error = null, + listFilter = null, + sortOrder = null, ) } else { null @@ -233,6 +250,8 @@ class SearchViewModel @Inject constructor( hasMore = true, list = emptyList(), error = error, + listFilter = null, + sortOrder = null, ) }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index 0a35e13a7..7a89cac70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -3,12 +3,13 @@ package org.koitharu.kotatsu.search.ui.suggestion import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.SearchKind interface SearchSuggestionListener { fun onMangaClick(manga: Manga) - fun onQueryClick(query: String, submit: Boolean) + fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) fun onQueryChanged(query: String) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt index f59b0d785..338309243 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt @@ -4,6 +4,7 @@ import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R 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.model.SearchSuggestionItem @@ -14,7 +15,7 @@ fun searchSuggestionAuthorAD( ) { 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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt index 70854cb2f..6fd65df1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt @@ -4,23 +4,25 @@ import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R 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.model.SearchSuggestionItem fun searchSuggestionQueryAD( listener: SearchSuggestionListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) } -) { +) = + adapterDelegateViewBinding( + { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }, + ) { - val viewClickListener = View.OnClickListener { v -> - listener.onQueryClick(item.query, v.id != R.id.button_complete) - } + val viewClickListener = View.OnClickListener { v -> + listener.onQueryClick(item.query, SearchKind.SIMPLE, v.id != R.id.button_complete) + } - binding.root.setOnClickListener(viewClickListener) - binding.buttonComplete.setOnClickListener(viewClickListener) + binding.root.setOnClickListener(viewClickListener) + binding.buttonComplete.setOnClickListener(viewClickListener) - bind { - binding.textViewTitle.text = item.query + bind { + binding.textViewTitle.text = item.query + } } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt index ede12ed52..f236603be 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding 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.model.SearchSuggestionItem @@ -13,7 +14,7 @@ fun searchSuggestionQueryHintAD( ) { val viewClickListener = View.OnClickListener { _ -> - listener.onQueryClick(item.query, true) + listener.onQueryClick(item.query, SearchKind.SIMPLE, true) } binding.root.setOnClickListener(viewClickListener) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 914f5ec69..1950674de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import com.google.android.material.R as materialR @@ -66,7 +67,7 @@ class SearchEditText @JvmOverloads constructor( && query.isNotEmpty() ) { cancelLongPress() - searchSuggestionListener?.onQueryClick(query, submit = true) + searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true) clearFocus() return true } @@ -76,7 +77,7 @@ class SearchEditText @JvmOverloads constructor( override fun onEditorAction(actionCode: Int) { super.onEditorAction(actionCode) if (actionCode == EditorInfo.IME_ACTION_SEARCH) { - searchSuggestionListener?.onQueryClick(query, submit = true) + searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index 1fa7bda4a..f9f377329 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -89,7 +89,7 @@ class SourcesCatalogActivity : BaseActivity(), } 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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt index 0c3fe09cb..620c6ed0f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt @@ -6,6 +6,7 @@ import android.content.Context import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import javax.inject.Inject @@ -39,10 +40,10 @@ class SyncSettings( companion object { - private fun String.withHttpSchema(): String = if (!startsWith("http://") && !startsWith("https://")) { - "http://$this" - } else { + private fun String.withHttpSchema(): String = if (isHttpUrl()) { this + } else { + "http://$this" } const val KEY_SYNC_URL = "host" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt index 1b04e6877..e4f000fed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt @@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R 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.databinding.PreferenceDialogAutocompletetextviewBinding import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty @@ -66,7 +67,7 @@ class SyncHostDialogFragment : AlertDialogFragment { val result = requireViewBinding().edit.text?.toString().orEmpty() var scheme = "" - if (!result.startsWith("https://") && !result.startsWith("http://")) { + if (!result.isHttpUrl()) { scheme = "http://" } syncSettings.syncUrl = "$scheme$result" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79a600ac3..dcd6d0e90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -801,4 +801,5 @@ Screen rotation has been locked Screen rotation has been unlocked Badges in lists + Search everywhere