From 547eb73f75861589450956e2512747e8e55daac5 Mon Sep 17 00:00:00 2001 From: MON5TERMATT Date: Sun, 23 Aug 2026 04:21:40 -0500 Subject: [PATCH] Fix Komga and Mihon extension integration in Futon. Expose extension settings across classloaders, preserve Komga API URLs when converting manga/chapter data, and improve Mihon network error handling. --- .../exceptions/resolve/ExceptionResolver.kt | 68 ++++++++++++++----- .../resolve/SnackbarErrorObserver.kt | 14 +++- .../futon/core/util/ext/Throwable.kt | 11 ++- .../futon/mihon/MihonMangaRepository.kt | 19 ++++-- .../futon/mihon/compat/MihonNetworkHelper.kt | 20 +++--- .../futon/mihon/model/MihonDataConverters.kt | 30 +++++++- .../settings/sources/SourceSettingsExt.kt | 22 +++++- 7 files changed, 141 insertions(+), 43 deletions(-) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt index 017b8e6a7c..45e74528d6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/ExceptionResolver.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.async import io.github.landwarderer.futon.R import io.github.landwarderer.futon.browser.BrowserActivity import io.github.landwarderer.futon.browser.cloudflare.CloudFlareActivity +import io.github.landwarderer.futon.core.exceptions.CloudFlareBlockedException +import io.github.landwarderer.futon.core.exceptions.CloudFlareException import io.github.landwarderer.futon.core.exceptions.CloudFlareProtectedException import io.github.landwarderer.futon.core.exceptions.EmptyMangaException import io.github.landwarderer.futon.core.exceptions.InteractiveActionRequiredException @@ -64,16 +66,24 @@ class ExceptionResolver private constructor( } suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async { - when (e) { - is CloudFlareProtectedException -> resolveCF(e) - is AuthRequiredException -> resolveAuthException(e.source) + when (val error = findResolvable(e) ?: e) { + is CloudFlareProtectedException -> resolveCF(error) + is CloudFlareBlockedException -> resolveBrowserAction( + InteractiveActionRequiredException(error.source, error.url), + ) + + is CloudFlareException -> resolveBrowserAction( + InteractiveActionRequiredException(error.source, error.url), + ) + + is AuthRequiredException -> resolveAuthException(error.source) is SSLException, is CertPathValidatorException -> { showSslErrorDialog() false } - is InteractiveActionRequiredException -> resolveBrowserAction(e) + is InteractiveActionRequiredException -> resolveBrowserAction(error) is ProxyConfigException -> { host.router.openProxySettings() @@ -81,32 +91,32 @@ class ExceptionResolver private constructor( } is NotFoundException -> { - openInBrowser(e.url) + openInBrowser(error.url) false } is EmptyMangaException -> { - when (e.reason) { - EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga) + when (error.reason) { + EmptyMangaReason.NO_CHAPTERS -> openAlternatives(error.manga) EmptyMangaReason.LOADING_ERROR -> Unit - EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga) + EmptyMangaReason.RESTRICTED -> host.router.openBrowser(error.manga) else -> Unit } false } is UnsupportedSourceException -> { - e.manga?.let { openAlternatives(it) } + error.manga?.let { openAlternatives(it) } false } is ScrobblerAuthRequiredException -> { val authHelper = scrobblerAuthHelperProvider.get() - if (authHelper.isAuthorized(e.scrobbler)) { + if (authHelper.isAuthorized(error.scrobbler)) { true } else { host.withContext { - authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails) + authHelper.startAuth(this, error.scrobbler).onFailure(::showErrorDetails) } false } @@ -227,29 +237,51 @@ class ExceptionResolver private constructor( companion object { @StringRes - fun getResolveStringId(e: Throwable) = when (e) { + fun getResolveStringId(e: Throwable) = when (val error = findResolvable(e)) { is CloudFlareProtectedException -> R.string.captcha_solve + is CloudFlareBlockedException, + is InteractiveActionRequiredException -> R.string._continue is ScrobblerAuthRequiredException, is AuthRequiredException -> R.string.sign_in - is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0 - is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 + is NotFoundException -> if (error.url.isHttpUrl()) R.string.open_in_browser else 0 + is UnsupportedSourceException -> if (error.manga != null) R.string.alternatives else 0 is SSLException, is CertPathValidatorException -> R.string.fix is ProxyConfigException -> R.string.settings - is InteractiveActionRequiredException -> R.string._continue - - is EmptyMangaException -> when (e.reason) { - EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0 + is EmptyMangaException -> when (error.reason) { + EmptyMangaReason.RESTRICTED -> if (error.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0 EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives else -> 0 } + null -> 0 else -> 0 } fun canResolve(e: Throwable) = getResolveStringId(e) != 0 + + fun findResolvable(e: Throwable): Throwable? { + var current: Throwable? = e + while (current != null) { + when (current) { + is CloudFlareProtectedException, + is CloudFlareBlockedException, + is InteractiveActionRequiredException, + is AuthRequiredException, + is NotFoundException, + is UnsupportedSourceException, + is ProxyConfigException, + is ScrobblerAuthRequiredException, + is EmptyMangaException, + is SSLException, + is CertPathValidatorException -> return current + } + current = current.cause + } + return null + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/SnackbarErrorObserver.kt index 84fca8380a..6e0bed1510 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/SnackbarErrorObserver.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/exceptions/resolve/SnackbarErrorObserver.kt @@ -4,7 +4,9 @@ import android.view.View import androidx.core.util.Consumer import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.EntryPointAccessors import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.ui.BaseActivityEntryPoint import io.github.landwarderer.futon.core.util.ext.getDisplayMessage import io.github.landwarderer.futon.core.util.ext.isSerializable import io.github.landwarderer.futon.main.ui.owners.BottomNavOwner @@ -21,7 +23,16 @@ class SnackbarErrorObserver( constructor( host: View, fragment: Fragment?, - ) : this(host, fragment, null, null) + ) : this( + host, + fragment, + fragment?.context?.let { context -> + EntryPointAccessors.fromApplication(context) + .exceptionResolverFactory + .create(fragment) + }, + null, + ) override suspend fun emit(value: Throwable) { val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) @@ -30,6 +41,7 @@ class SnackbarErrorObserver( is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet } if (canResolve(value)) { + snackbar.duration = Snackbar.LENGTH_INDEFINITE snackbar.setAction(ExceptionResolver.getResolveStringId(value)) { resolve(value) } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt index 20b2b7e36e..c066e4bfd4 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/util/ext/Throwable.kt @@ -66,7 +66,13 @@ private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$") fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) ?: resources.getString(R.string.error_occurred) -private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) { +private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? { + ExceptionResolver.findResolvable(this)?.let { resolved -> + if (resolved !== this) { + return resolved.getDisplayMessageOrNull(resources) + } + } + return when (this) { is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message is CaughtException -> cause.getDisplayMessageOrNull(resources) is WrapperIOException -> cause.getDisplayMessageOrNull(resources) @@ -141,7 +147,8 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) else -> mapDisplayMessage(message, resources) ?: message -}.takeUnless { it.isNullOrBlank() } + }.takeUnless { it.isNullOrBlank() } +} @DrawableRes fun Throwable.getDisplayIcon(): Int = when (this) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt index 1af8906431..c88000a836 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/MihonMangaRepository.kt @@ -340,13 +340,22 @@ class MihonMangaRepository( try { return block() } catch (e: RuntimeException) { - when (val cause = e.cause) { - is CloudFlareException -> throw cause - is InteractiveActionRequiredException -> throw cause - is java.io.IOException -> throw cause - else -> throw e + throw unwrapMihonNetworkException(e.cause ?: e) + } catch (e: java.io.IOException) { + throw unwrapMihonNetworkException(e) + } + } + + private fun unwrapMihonNetworkException(error: Throwable): Throwable { + var current: Throwable? = error + while (current != null) { + when (current) { + is CloudFlareException, + is InteractiveActionRequiredException -> return current } + current = current.cause } + return error } override suspend fun getRelatedMangaImpl(seed: Manga): List = emptyList() diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt index ed5be4207a..df076e7eef 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/compat/MihonNetworkHelper.kt @@ -4,6 +4,7 @@ import android.util.Log import eu.kanade.tachiyomi.network.NetworkHelper import io.github.landwarderer.futon.core.exceptions.CloudFlareBlockedException import io.github.landwarderer.futon.core.exceptions.InteractiveActionRequiredException +import io.github.landwarderer.futon.core.model.UnknownMangaSource import io.github.landwarderer.futon.core.network.webview.WebViewExecutor import io.github.landwarderer.futon.mihon.model.toMangaSource import io.github.landwarderer.futon.mihon.parsers.model.ContentSource @@ -136,18 +137,13 @@ class MihonNetworkHelper( ), ) } else { - val source = request.tag(ContentSource::class.java) - if (source == null) { - Log.w("MihonNetwork", "Missing ContentSource tag for host=$host") - response.closeThrowing(CloudFlareBlockedException(url = challengeUrl, source = null)) - } else { - response.closeThrowing( - InteractiveActionRequiredException( - source = source.toMangaSource(), - url = challengeUrl, - ), - ) - } + val source = request.tag(ContentSource::class.java)?.toMangaSource() ?: UnknownMangaSource + response.closeThrowing( + InteractiveActionRequiredException( + source = source, + url = challengeUrl, + ), + ) } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt index bcba6340f9..ddeb0fcff8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/mihon/model/MihonDataConverters.kt @@ -110,9 +110,13 @@ fun Content.toMihonManga(): SManga { // Fix malformed protocols (https// -> https://) cleanUrl = cleanUrl.replace(Regex("^(https?)/+"), "$1://") - + + // Komga overrides HttpSource requests to pass manga.url directly to GET(), not baseUrl + url. + // Keep absolute API URLs intact and resolve relative /api/v1/... paths once the server is configured. + val isKomgaStyleApi = cleanUrl.contains("/api/v1/") + // If URL is absolute and starts with baseUrl, strip it to avoid duplicates in HttpSource - if (baseUrl.isNotBlank()) { + if (baseUrl.isNotBlank() && !isKomgaStyleApi) { val baseHost = baseUrl.trimEnd('/') if (cleanUrl.startsWith(baseHost)) { val stripped = cleanUrl.substring(baseHost.length) @@ -122,6 +126,11 @@ fun Content.toMihonManga(): SManga { } } } + + if (isKomgaStyleApi && !cleanUrl.matches(Regex("^https?://.*")) && baseUrl.isNotBlank()) { + cleanUrl = baseUrl.trimEnd('/') + cleanUrl + android.util.Log.d("MihonDataConverters", "Resolved Komga API URL: '$url' -> '$cleanUrl'") + } // If URL still doesn't look absolute, log warning if (!cleanUrl.matches(Regex("^https?://.*")) && !cleanUrl.startsWith("/")) { @@ -184,8 +193,13 @@ fun SChapter.toContentChapter(source: ContentSource, overrideNumber: Float? = nu * Convert Apps ContentChapter to Mihon SChapter. */ fun ContentChapter.toMihonChapter(): SChapter { + val baseUrl = (source as? MihonMangaSource)?.let { mihonSource -> + (mihonSource.catalogueSource as? HttpSource)?.baseUrl ?: "" + } ?: "" + val chapterUrl = resolveMihonRequestUrl(url, baseUrl) + return SChapter.create().apply { - this.url = this@toMihonChapter.url + this.url = chapterUrl this.name = this@toMihonChapter.title ?: "Chapter ${this@toMihonChapter.number}" this.chapter_number = this@toMihonChapter.number this.date_upload = this@toMihonChapter.uploadDate @@ -273,6 +287,16 @@ fun HttpSource.getPublicChapterUrl(chapter: SChapter): String { /** * Resolve relative URL using baseUrl. */ +private fun resolveMihonRequestUrl(url: String, baseUrl: String): String { + if (url.isBlank() || url.matches(Regex("^https?://.*"))) { + return url + } + if (baseUrl.isBlank()) { + return url + } + return baseUrl.trimEnd('/') + "/" + url.trimStart('/') +} + private fun resolveUrl(baseUrl: String, url: String?): String? { if (url.isNullOrBlank()) return null if (url.startsWith("http")) return url diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt index 7c28a4cd8c..ea89e34db3 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/settings/sources/SourceSettingsExt.kt @@ -6,7 +6,9 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.PreferenceScreen import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.parser.EmptyMangaRepository import io.github.landwarderer.futon.core.parser.MangaRepository @@ -30,14 +32,30 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: MangaRepos } private fun PreferenceFragmentCompat.addPreferencesFromMihonRepository(repository: MihonMangaRepository) { - val configurableSource = repository.mihonSource as? ConfigurableSource ?: return runCatching { - configurableSource.setupPreferenceScreen(preferenceScreen) + setupMihonPreferenceScreen(repository.mihonSource, preferenceScreen) }.onFailure { it.printStackTraceDebug() } } +/** + * Extension APKs ship their own copy of [ConfigurableSource], so a direct cast from an + * extension [CatalogueSource] usually fails even when the source implements the interface. + */ +private fun setupMihonPreferenceScreen(source: CatalogueSource, screen: PreferenceScreen) { + if (source is ConfigurableSource) { + source.setupPreferenceScreen(screen) + return + } + val method = source.javaClass.methods.firstOrNull { candidate -> + candidate.name == "setupPreferenceScreen" && + candidate.parameterCount == 1 && + PreferenceScreen::class.java.isAssignableFrom(candidate.parameterTypes[0]) + } ?: return + method.invoke(source, screen) +} + private fun PreferenceFragmentCompat.addPreferencesFromParserRepository(repository: ParserMangaRepository) { addPreferencesFromResource(R.xml.pref_source_parser) val configKeys = repository.getConfigKeys()