From 0c92ec23eb2388a6deb17007d0886a33328902e9 Mon Sep 17 00:00:00 2001 From: jeremychoco Date: Tue, 24 Mar 2026 03:55:24 +0700 Subject: [PATCH 1/3] feat: Add InputRequiredException handling for user-required input requests feat: Add InputRequiredException handling for user-required input requests feat: Add InputRequiredException handling for user-required input requests --- .idea/.name | 1 + app/build.gradle | 3 +- .../exceptions/resolve/DialogErrorObserver.kt | 32 +++++-- .../core/exceptions/resolve/ErrorObserver.kt | 5 ++ .../exceptions/resolve/ExceptionResolver.kt | 84 +++++++++++++++---- .../draken/usagi/core/prefs/SourceSettings.kt | 21 +++-- .../draken/usagi/core/util/ext/Throwable.kt | 4 +- .../settings/sources/SourceSettingsExt.kt | 15 ++-- app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 2 +- 10 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 .idea/.name diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..a261d6f --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Usagi \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 0500bb3..3532972 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,7 +20,7 @@ android { minSdk = 23 targetSdk = 36 versionCode = 1 - versionName = '0.0.1-a1' + versionName = '0.0.1-a2' generatedDensities = [] testInstrumentationRunner 'org.draken.usagi.HiltTestRunner' ksp { @@ -105,6 +105,7 @@ android { } } } + compileSdkMinor 1 } dependencies { implementation(libs.kotatsu.parsers) { diff --git a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt index dd0d9da..256c3ab 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt @@ -8,6 +8,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.draken.usagi.R import org.draken.usagi.core.util.ext.getDisplayMessage import org.draken.usagi.core.util.ext.isSerializable +import org.koitharu.kotatsu.parsers.exception.InputRequiredException import org.koitharu.kotatsu.parsers.exception.ParseException class DialogErrorObserver( @@ -23,18 +24,35 @@ class DialogErrorObserver( ) : this(host, fragment, null, null) override suspend fun emit(value: Throwable) { + if (value is InputRequiredException && canResolve(value)) { + val resolved = resolveAndWait(value) + if (resolved) { + onResolved?.accept(true) + return + } + showErrorDialog(value, true) + return + } + showErrorDialog(value, false) + } + + private fun showErrorDialog(value: Throwable, isRetry: Boolean) { val listener = DialogListener(value) val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context) .setMessage(value.getDisplayMessage(host.context.resources)) .setNegativeButton(R.string.close, listener) .setOnCancelListener(listener) - if (canResolve(value)) { - dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) - } else if (value is ParseException) { - val router = router() - if (router != null && value.isSerializable()) { - dialogBuilder.setPositiveButton(R.string.details) { _, _ -> - router.showErrorDialog(value) + when { + isRetry -> + dialogBuilder.setPositiveButton(android.R.string.ok, listener) + canResolve(value) -> + dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) + value is ParseException -> { + val router = router() + if (router != null && value.isSerializable()) { + dialogBuilder.setPositiveButton(R.string.details) { _, _ -> + router.showErrorDialog(value) + } } } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ErrorObserver.kt b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ErrorObserver.kt index 737b12a..6a69638 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ErrorObserver.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ErrorObserver.kt @@ -55,4 +55,9 @@ abstract class ErrorObserver( } } } + + protected suspend fun resolveAndWait(error: Throwable): Boolean { + if (!isAlive()) return false + return resolver?.resolve(error) == true + } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ExceptionResolver.kt index 4b5805b..441088e 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/ExceptionResolver.kt @@ -1,9 +1,13 @@ package org.draken.usagi.core.exceptions.resolve import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.activity.result.ActivityResultCaller import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.collection.MutableScatterMap import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -12,6 +16,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.async +import kotlinx.coroutines.suspendCancellableCoroutine import org.draken.usagi.R import org.draken.usagi.browser.BrowserActivity import org.draken.usagi.browser.cloudflare.CloudFlareActivity @@ -23,7 +28,9 @@ import org.draken.usagi.core.exceptions.UnsupportedSourceException import org.draken.usagi.core.nav.AppRouter import org.draken.usagi.core.nav.router import org.draken.usagi.core.prefs.AppSettings +import org.draken.usagi.core.prefs.SourceSettings import org.draken.usagi.core.ui.dialog.buildAlertDialog +import org.draken.usagi.core.ui.dialog.setEditText import org.draken.usagi.core.util.ext.isHttpUrl import org.draken.usagi.core.util.ext.restartApplication import org.draken.usagi.details.ui.pager.EmptyMangaReason @@ -34,13 +41,14 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.draken.usagi.scrobbling.common.domain.ScrobblerAuthRequiredException import org.draken.usagi.scrobbling.common.ui.ScrobblerAuthHelper import org.draken.usagi.settings.sources.auth.SourceAuthActivity +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.exception.InputRequiredException import java.security.cert.CertPathValidatorException import javax.inject.Inject import javax.inject.Provider import javax.net.ssl.SSLException import kotlin.coroutines.Continuation import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class ExceptionResolver private constructor( private val host: Host, @@ -75,6 +83,8 @@ class ExceptionResolver private constructor( is InteractiveActionRequiredException -> resolveBrowserAction(e) + is InputRequiredException -> resolveUserInput(e) + is ProxyConfigException -> { host.router.openProxySettings() false @@ -118,20 +128,63 @@ class ExceptionResolver private constructor( private suspend fun resolveBrowserAction( e: InteractiveActionRequiredException - ): Boolean = suspendCoroutine { cont -> - continuations[BrowserActivity.TAG] = cont - browserActionContract.launch(e) - } - - private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont -> - continuations[CloudFlareActivity.TAG] = cont - cloudflareContract.launch(e) - } - - private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> - continuations[SourceAuthActivity.TAG] = cont - sourceAuthContract.launch(source) - } + ): Boolean = suspendCancellableCoroutine { cont -> + continuations[BrowserActivity.TAG] = cont + browserActionContract.launch(e) + } + + private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCancellableCoroutine { cont -> + continuations[CloudFlareActivity.TAG] = cont + cloudflareContract.launch(e) + } + + private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCancellableCoroutine { cont -> + continuations[SourceAuthActivity.TAG] = cont + sourceAuthContract.launch(source) + } + + private suspend fun resolveUserInput(e: InputRequiredException): Boolean = suspendCancellableCoroutine { cont -> + val ctx = host.context + if (ctx == null) { + cont.resume(false) + return@suspendCancellableCoroutine + } + var editText: android.widget.EditText? = null + buildAlertDialog(ctx) { + setTitle(R.string.user_input_required) + setMessage(e.message) + editText = setEditText(EditorInfo.TYPE_CLASS_TEXT, true) + setPositiveButton(android.R.string.ok, null) + setNegativeButton(android.R.string.cancel) { _, _ -> cont.resume(false) } + setOnCancelListener { cont.resume(false) } + }.also { builtDialog -> + builtDialog.setOnShowListener { + val okBtn = builtDialog.getButton(AlertDialog.BUTTON_POSITIVE) + okBtn.isEnabled = false + editText?.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { + okBtn.isEnabled = !s.isNullOrBlank() + } + } + ) + okBtn.setOnClickListener { + val value = editText?.text?.toString().orEmpty() + if (value.isNotBlank()) { + if (e.key.isNotEmpty()) { + val sourceSettings = SourceSettings(ctx, e.source) + sourceSettings[ConfigKey.UserInput(e.key)] = value + } + builtDialog.dismiss() + cont.resume(true) + } + } + } + builtDialog.show() + } + } private fun openInBrowser(url: String) { host.router.openBrowser(url, null, null) @@ -240,6 +293,7 @@ class ExceptionResolver private constructor( is ProxyConfigException -> R.string.settings is InteractiveActionRequiredException -> R.string._continue + is InputRequiredException -> android.R.string.ok is EmptyMangaException -> when (e.reason) { EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0 diff --git a/app/src/main/kotlin/org/draken/usagi/core/prefs/SourceSettings.kt b/app/src/main/kotlin/org/draken/usagi/core/prefs/SourceSettings.kt index d6129d7..178629d 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/prefs/SourceSettings.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/prefs/SourceSettings.kt @@ -22,6 +22,8 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig Context.MODE_PRIVATE, ) + private val userInputs = HashMap() + var defaultSortOrder: SortOrder? get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } @@ -47,16 +49,23 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.nullIfEmpty() + is ConfigKey.UserInput -> userInputs.remove(key.key) } as T } - operator fun set(key: ConfigKey, value: T) = prefs.edit { + operator fun set(key: ConfigKey, value: T) { when (key) { - is ConfigKey.Domain -> putString(key.key, value as String?) - is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) - is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) - is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) - is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "") + is ConfigKey.UserInput -> if (value != null) userInputs[key.key] = value as String else userInputs.remove(key.key) + else -> prefs.edit { + when (key) { + is ConfigKey.Domain -> putString(key.key, value as String?) + is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) + is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue()) + is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean) + is ConfigKey.PreferredImageServer -> putString(key.key, value as String? ?: "") + else -> Unit + } + } } } diff --git a/app/src/main/kotlin/org/draken/usagi/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/draken/usagi/core/util/ext/Throwable.kt index 6afabc1..313eac7 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/util/ext/Throwable.kt @@ -46,6 +46,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.draken.usagi.scrobbling.common.domain.ScrobblerAuthRequiredException +import org.koitharu.kotatsu.parsers.exception.InputRequiredException import java.io.File import java.net.ConnectException import java.net.HttpURLConnection @@ -75,6 +76,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w ) is AuthRequiredException -> resources.getString(R.string.auth_required) + is InputRequiredException -> message is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message) is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) @@ -154,7 +156,7 @@ fun Throwable.getDisplayIcon(): Int = when (this) { is CloudFlareBlockedException -> R.drawable.ic_denied_large - is InteractiveActionRequiredException -> R.drawable.ic_interaction_large + is InteractiveActionRequiredException, is InputRequiredException -> R.drawable.ic_interaction_large else -> R.drawable.ic_error_large } diff --git a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt index 60e2b70..0980c95 100644 --- a/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/kotlin/org/draken/usagi/settings/sources/SourceSettingsExt.kt @@ -30,7 +30,7 @@ private fun PreferenceFragmentCompat.addPreferencesFromParserRepository(reposito val configKeys = repository.getConfigKeys() val screen = preferenceScreen for (key in configKeys) { - val preference: Preference = when (key) { + val preference: Preference? = when (key) { is ConfigKey.Domain -> { val presetValues = key.presetValues if (presetValues.size <= 1) { @@ -101,11 +101,16 @@ private fun PreferenceFragmentCompat.addPreferencesFromParserRepository(reposito summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() } } + + is ConfigKey.UserInput -> null + } + + if (preference != null) { + preference.isIconSpaceReserved = false + preference.key = key.key + preference.order = 10 + screen.addPreference(preference) } - preference.isIconSpaceReserved = false - preference.key = key.key - preference.order = 10 - screen.addPreference(preference) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3b1067..4d0ff6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -854,6 +854,7 @@ This manga may contain adult content. Do you want to use incognito mode? Incognito mode for NSFW manga Additional action is required + Input required Hide from main screen Changelog Changes history for recently released versions diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89c368c..4a24a5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ material = "1.14.0-alpha08" moshi = "1.15.2" okhttp = "5.3.2" okio = "3.16.4" -parsers = "868b791ddc" +parsers = "ff0d4e5a64" preference = "1.2.1" recyclerview = "1.4.0" room = "2.7.2" From ba2d6e54407a3d1f7fe8dd01509daff737740ad2 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Tue, 24 Mar 2026 04:04:38 +0700 Subject: [PATCH 2/3] Add gradle config file for Usagi project --- .gitignore | 1 - gradle.properties | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 gradle.properties diff --git a/.gitignore b/.gitignore index 10189fe..35de902 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,3 @@ /.idea/deviceManager.xml /.kotlin/ /.idea/AndroidProjectSystem.xml -/gradle.properties \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..672c31e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,26 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +#Tue Mar 24 03:33:40 ICT 2026 +android.enableJetifier=false +android.nonTransitiveRClass=true +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M" +android.enableR8.fullMode=true +android.nonFinalResIds=false +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.vfs.watch=true +org.gradle.parallel=true +org.gradle.workers.max=8 +org.gradle.configuration-cache.max-problems=8 From 22140e977f48d264a0d68d0d9efc3e27026fd83e Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Tue, 24 Mar 2026 04:09:46 +0700 Subject: [PATCH 3/3] Small tweaks --- .../core/exceptions/resolve/DialogErrorObserver.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt index 256c3ab..01b5090 100644 --- a/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/draken/usagi/core/exceptions/resolve/DialogErrorObserver.kt @@ -43,16 +43,12 @@ class DialogErrorObserver( .setNegativeButton(R.string.close, listener) .setOnCancelListener(listener) when { - isRetry -> - dialogBuilder.setPositiveButton(android.R.string.ok, listener) - canResolve(value) -> - dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) + isRetry -> dialogBuilder.setPositiveButton(android.R.string.ok, listener) + canResolve(value) -> dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) value is ParseException -> { val router = router() if (router != null && value.isSerializable()) { - dialogBuilder.setPositiveButton(R.string.details) { _, _ -> - router.showErrorDialog(value) - } + dialogBuilder.setPositiveButton(R.string.details) { _, _ -> router.showErrorDialog(value) } } } }