Skip to content
Open
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,3 @@
/.idea/deviceManager.xml
/.kotlin/
/.idea/AndroidProjectSystem.xml
/gradle.properties
1 change: 1 addition & 0 deletions .idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,6 +105,7 @@ android {
}
}
}
compileSdkMinor 1
}
dependencies {
implementation(libs.kotatsu.parsers) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,18 +24,31 @@ 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) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,9 @@ abstract class ErrorObserver(
}
}
}

protected suspend fun resolveAndWait(error: Throwable): Boolean {
if (!isAlive()) return false
return resolver?.resolve(error) == true
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -75,6 +83,8 @@ class ExceptionResolver private constructor(

is InteractiveActionRequiredException -> resolveBrowserAction(e)

is InputRequiredException -> resolveUserInput(e)

is ProxyConfigException -> {
host.router.openProxySettings()
false
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions app/src/main/kotlin/org/draken/usagi/core/prefs/SourceSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
Context.MODE_PRIVATE,
)

private val userInputs = HashMap<String, String>()

var defaultSortOrder: SortOrder?
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
Expand All @@ -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 <T> set(key: ConfigKey<T>, value: T) = prefs.edit {
operator fun <T> set(key: ConfigKey<T>, 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
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@
<string name="incognito_mode_hint_nsfw">This manga may contain adult content. Do you want to use incognito mode?</string>
<string name="incognito_for_nsfw">Incognito mode for NSFW manga</string>
<string name="additional_action_required">Additional action is required</string>
<string name="user_input_required">Input required</string>
<string name="hide_from_main_screen">Hide from main screen</string>
<string name="changelog">Changelog</string>
<string name="changelog_summary">Changes history for recently released versions</string>
Expand Down
26 changes: 26 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down