Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,49 +66,57 @@ 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()
false
}

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
}
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<BaseActivityEntryPoint>(context)
.exceptionResolverFactory
.create(fragment)
},
null,
)

override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Manga> = emptyList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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("/")) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Loading