Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 |
@@ -21,8 +21,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1032
|
||||
versionName = '9.4'
|
||||
versionCode = 1033
|
||||
versionName = '9.4.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class EmptyMangaException(
|
||||
val reason: EmptyMangaReason?,
|
||||
val manga: Manga,
|
||||
cause: Throwable?
|
||||
) : IllegalStateException(cause)
|
||||
@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.async
|
||||
import org.koitharu.kotatsu.R
|
||||
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.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
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 @AssistedInject constructor(
|
||||
@Assisted private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
class ExceptionResolver private constructor(
|
||||
private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
}
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||
when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.router()?.openProxySettings()
|
||||
false
|
||||
}
|
||||
is ProxyConfigException -> {
|
||||
host.router.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
is EmptyMangaException -> {
|
||||
when (e.reason) {
|
||||
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||
else -> Unit
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}.await()
|
||||
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
}
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
}
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.context ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
}
|
||||
class Factory @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
fun create(fragment: Fragment) = ExceptionResolver(
|
||||
host = Host.FragmentHost(fragment),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||
host = Host.ActivityHost(activity),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
}
|
||||
|
||||
fun getContext(): Context?
|
||||
}
|
||||
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
val context: Context?
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
}
|
||||
val router: AppRouter
|
||||
|
||||
companion object {
|
||||
val fragmentManager: FragmentManager
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
inline fun withContext(block: Context.() -> Unit) {
|
||||
context?.apply(block)
|
||||
}
|
||||
|
||||
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 SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||
ActivityResultCaller by activity,
|
||||
LifecycleOwner by activity {
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
override val context: Context
|
||||
get() = activity
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
override val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
else -> 0
|
||||
}
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = activity.supportFragmentManager
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
class FragmentHost(val fragment: Fragment) : Host,
|
||||
ActivityResultCaller by fragment {
|
||||
|
||||
override val context: Context?
|
||||
get() = fragment.context
|
||||
|
||||
override val router: AppRouter
|
||||
get() = fragment.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = fragment.childFragmentManager
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = fragment.viewLifecycleOwner.lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
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 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
|
||||
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
|
||||
else -> 0
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -681,7 +681,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
OnApplyWindowInsetsListener,
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
|
||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
OnApplyWindowInsetsListener,
|
||||
Fragment(),
|
||||
ExceptionResolver.Host {
|
||||
Fragment() {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
RecyclerViewOwner {
|
||||
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||
OnApplyWindowInsetsListener,
|
||||
ExceptionResolver.Host {
|
||||
OnApplyWindowInsetsListener {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
@@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
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)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
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)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
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)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
|
||||
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 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)
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
}.takeUnless { it.isNullOrBlank() }
|
||||
|
||||
@DrawableRes
|
||||
fun Throwable.getDisplayIcon(): Int = when (this) {
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
}
|
||||
|
||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Throwable.isReportable(): Boolean {
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
}
|
||||
|
||||
fun Throwable.report(silent: Boolean = false) {
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun FileNotFoundException.getFile(): File? {
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
}
|
||||
|
||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
val isRestricted: Boolean
|
||||
get() = manga.state == MangaState.RESTRICTED
|
||||
|
||||
fun toManga() = mergedManga
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
val binding = viewBinding ?: return
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
||||
scrollView.scrollIndicators = 0
|
||||
buttonDone.isVisible = false
|
||||
this.root.updateLayoutParams {
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
weight = 0f
|
||||
width = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
|
||||
readerAdapter = onCreateAdapter()
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
||||
onPagesChanged(it.pages, viewModel.getCurrentState())
|
||||
} else {
|
||||
onPagesChanged(it.pages, it.state)
|
||||
// Determine which state to use for restoring position:
|
||||
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
|
||||
// - getCurrentState(): current reading position saved in SavedStateHandle
|
||||
val currentState = viewModel.getCurrentState()
|
||||
val pendingState = when {
|
||||
// If content.state is null and we have pages, use getCurrentState
|
||||
it.state == null
|
||||
&& it.pages.isNotEmpty()
|
||||
&& readerAdapter?.hasItems != true -> currentState
|
||||
|
||||
// use currentState only if it matches the current pages (to avoid the error message)
|
||||
readerAdapter?.hasItems != true
|
||||
&& it.state != currentState
|
||||
&& currentState != null
|
||||
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
|
||||
|
||||
// Otherwise, use content.state (normal flow, mode switch, chapter change)
|
||||
else -> it.state
|
||||
}
|
||||
onPagesChanged(it.pages, pendingState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user