Advanced global search
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.search.domain
|
||||||
|
|
||||||
|
enum class SearchKind {
|
||||||
|
|
||||||
|
SIMPLE, TITLE, AUTHOR, TAG
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user