From 7c2829226df5df6ec4d696b28d0982e99f5138e6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 15 Mar 2024 15:51:28 +0200 Subject: [PATCH] Improve alternatives search --- app/build.gradle | 6 +- .../domain/AlternativesUseCase.kt | 18 +++++- .../kotatsu/alternatives/ui/AlternativeAD.kt | 6 +- .../alternatives/ui/AlternativesViewModel.kt | 8 ++- .../exceptions/UnsupportedSourceException.kt | 8 +++ .../core/exceptions/resolve/ErrorObserver.kt | 4 +- .../exceptions/resolve/ExceptionResolver.kt | 14 +++++ .../kotatsu/core/parser/DummyParser.kt | 21 +++---- .../kotatsu/core/util/ext/Throwable.kt | 6 ++ .../kotatsu/details/ui/DetailsActivity.kt | 15 +---- .../details/ui/DetailsErrorObserver.kt | 56 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../kotatsu/core/parser/DummyParser.kt | 35 ------------ 13 files changed, 128 insertions(+), 70 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedSourceException.kt rename app/src/{debug => main}/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt (72%) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt delete mode 100644 app/src/release/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt diff --git a/app/build.gradle b/app/build.gradle index 56b9bfbd2..51944f5a7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 34 - versionCode = 627 - versionName = '6.7.5' + versionCode = 628 + versionName = '6.8-a1' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:c6d1f1b525') { + implementation('com.github.KotatsuApp:kotatsu-parsers:fec60955ed') { exclude group: 'org.json', module: 'json' } 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 83ea79193..6985bad9a 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 @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals 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.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @@ -24,7 +25,7 @@ class AlternativesUseCase @Inject constructor( ) { suspend operator fun invoke(manga: Manga): Flow { - val sources = sourcesRepository.getEnabledSources() + val sources = getSources(manga.source) if (sources.isEmpty()) { return emptyFlow() } @@ -55,6 +56,14 @@ class AlternativesUseCase @Inject constructor( } } + private suspend fun getSources(ref: MangaSource): List { + val result = ArrayList(MangaSource.entries.size - 2) + result.addAll(sourcesRepository.getEnabledSources()) + result.sortByDescending { it.priority(ref) } + result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) + return result + } + private fun Manga.matches(ref: Manga): Boolean { return matchesTitles(title, ref.title) || matchesTitles(title, ref.altTitle) || @@ -66,4 +75,11 @@ class AlternativesUseCase @Inject constructor( private fun matchesTitles(a: String?, b: String?): Boolean { return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD) } + + private fun MangaSource.priority(ref: MangaSource): Int { + var res = 0 + if (locale == ref.locale) res += 2 + if (contentType == ref.contentType) res++ + return res + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt index 9c2f4f7a0..377ecd327 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt @@ -43,7 +43,11 @@ fun alternativeAD( bind { payloads -> binding.textViewTitle.text = item.manga.title binding.textViewSubtitle.text = buildSpannedString { - append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount)) + if (item.chaptersCount > 0) { + append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount)) + } else { + append(context.getString(R.string.no_chapters)) + } when (item.chaptersDiff.sign) { -1 -> inSpans(ForegroundColorSpan(colorRed)) { append(" ▼ ") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt index b8111042d..64eab5e0b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @HiltViewModel @@ -44,9 +45,11 @@ class AlternativesViewModel @Inject constructor( init { launchJob(Dispatchers.Default) { - val ref = mangaRepositoryFactory.create(manga.source).getDetails(manga) + val ref = runCatchingCancellable { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + }.getOrDefault(manga) val refCount = ref.chaptersCount() - alternativesUseCase(manga) + alternativesUseCase(ref) .map { MangaAlternativeModel( manga = it, @@ -69,6 +72,7 @@ class AlternativesViewModel @Inject constructor( }.collect { content.value = it } + content.value = content.value.filterNot { it is LoadingFooter } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedSourceException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedSourceException.kt new file mode 100644 index 000000000..4aee469fc --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedSourceException.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.exceptions + +import org.koitharu.kotatsu.parsers.model.Manga + +class UnsupportedSourceException( + message: String?, + val manga: Manga?, +) : IllegalArgumentException(message) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt index f14a42f79..097c562a5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt @@ -21,7 +21,7 @@ abstract class ErrorObserver( private val onResolved: Consumer?, ) : FlowCollector { - protected val activity = host.context.findActivity() + protected open val activity = host.context.findActivity() private val lifecycleScope: LifecycleCoroutineScope get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope) @@ -36,7 +36,7 @@ abstract class ErrorObserver( private fun isAlive(): Boolean { return when { fragment != null -> fragment.view != null - activity != null -> !activity.isDestroyed + activity != null -> activity?.isDestroyed == false else -> true } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index 9407ab6e9..49cc7cf3f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -8,13 +8,16 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import okhttp3.Headers import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import kotlin.coroutines.Continuation @@ -59,6 +62,11 @@ class ExceptionResolver : ActivityResultCallback { false } + is UnsupportedSourceException -> { + e.manga?.let { openAlternatives(it) } + false + } + else -> false } @@ -77,6 +85,11 @@ class ExceptionResolver : ActivityResultCallback { context.startActivity(BrowserActivity.newIntent(context, url, null)) } + private fun openAlternatives(manga: Manga) { + val context = activity ?: fragment?.activity ?: return + context.startActivity(AlternativesActivity.newIntent(context, manga)) + } + private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) companion object { @@ -86,6 +99,7 @@ class ExceptionResolver : ActivityResultCallback { is CloudFlareProtectedException -> R.string.captcha_solve is AuthRequiredException -> R.string.sign_in is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0 + is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 else -> 0 } diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt similarity index 72% rename from app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt index 906269bdf..5d05b9c0a 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.parser +import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.config.ConfigKey @@ -18,24 +19,20 @@ import java.util.EnumSet class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { override val configKeyDomain: ConfigKey.Domain - get() = ConfigKey.Domain("") + get() = ConfigKey.Domain("localhost") override val availableSortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) - override suspend fun getDetails(manga: Manga): Manga { - TODO("Not yet implemented") - } + override suspend fun getDetails(manga: Manga): Manga = stub(manga) - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { - TODO("Not yet implemented") - } + override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub(null) - override suspend fun getPages(chapter: MangaChapter): List { - TODO("Not yet implemented") - } + override suspend fun getPages(chapter: MangaChapter): List = stub(null) - override suspend fun getAvailableTags(): Set { - TODO("Not yet implemented") + override suspend fun getAvailableTags(): Set = stub(null) + + private fun stub(manga: Manga?): Nothing { + throw UnsupportedSourceException("Usage of Dummy parser", manga) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index aeb3de5c8..957171522 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException +import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED @@ -56,6 +57,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { is WrongPasswordException -> resources.getString(R.string.wrong_password) is NotFoundException -> resources.getString(R.string.not_found_404) + is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) @@ -98,6 +100,10 @@ fun Throwable.isReportable(): Boolean { return this is Error || this.javaClass in reportableExceptions } +fun Throwable.isNetworkError(): Boolean { + return this is UnknownHostException || this is SocketTimeoutException +} + fun Throwable.report() { val exception = CaughtException(this, "${javaClass.simpleName}($message)") exception.sendWithAcra() 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 8d56c0d1b..5d9aa492d 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 @@ -36,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaIntent @@ -125,19 +124,7 @@ class DetailsActivity : viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) - viewModel.onError.observeEvent( - this, - SnackbarErrorObserver( - host = viewBinding.containerDetails, - fragment = null, - resolver = exceptionResolver, - onResolved = { isResolved -> - if (isResolved) { - viewModel.reload() - } - }, - ), - ) + viewModel.onError.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver)) viewModel.onActionDone.observeEvent( this, ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt new file mode 100644 index 000000000..c879cea9b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.details.ui + +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException +import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.isNetworkError +import org.koitharu.kotatsu.parsers.exception.NotFoundException +import org.koitharu.kotatsu.parsers.exception.ParseException + +class DetailsErrorObserver( + override val activity: DetailsActivity, + private val viewModel: DetailsViewModel, + resolver: ExceptionResolver?, +) : ErrorObserver( + activity.viewBinding.containerDetails, null, resolver, + { isResolved -> + if (isResolved) { + viewModel.reload() + } + }, +) { + + override suspend fun emit(value: Throwable) { + val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) + if (value is NotFoundException || value is UnsupportedSourceException) { + snackbar.duration = Snackbar.LENGTH_INDEFINITE + } + when { + canResolve(value) -> { + snackbar.setAction(ExceptionResolver.getResolveStringId(value)) { + resolve(value) + } + } + + value is ParseException -> { + val fm = fragmentManager + if (fm != null) { + snackbar.setAction(R.string.details) { + ErrorDetailsDialog.show(fm, value, value.url) + } + } + } + + value.isNetworkError() -> { + snackbar.setAction(R.string.try_again) { + viewModel.reload() + } + } + } + snackbar.show() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42451c9b5..89d165855 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -637,4 +637,5 @@ Long time ago read Unread Enable source + This manga source is not supported diff --git a/app/src/release/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/release/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt deleted file mode 100644 index 52d174bcf..000000000 --- a/app/src/release/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaParser -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.exception.NotFoundException -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -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 java.util.EnumSet - -class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { - - override val configKeyDomain: ConfigKey.Domain - get() = ConfigKey.Domain("localhost") - - override val availableSortOrders: Set - get() = EnumSet.allOf(SortOrder::class.java) - - override suspend fun getDetails(manga: Manga): Manga = stub() - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List = stub() - - override suspend fun getPages(chapter: MangaChapter): List = stub() - - override suspend fun getAvailableTags(): Set = stub() - - private fun stub(): Nothing { - throw NotFoundException("Usage of Dummy parser in release build", "") - } -}