Avoid memory leak in ExceptionResolver

(cherry picked from commit 7a3b2a9bb4)
This commit is contained in:
Koitharu
2025-11-02 10:56:23 +02:00
parent 881f154b5e
commit cceaefc896
5 changed files with 171 additions and 137 deletions

View File

@@ -8,9 +8,10 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted import androidx.lifecycle.Lifecycle
import dagger.assisted.AssistedFactory import androidx.lifecycle.LifecycleOwner
import dagger.assisted.AssistedInject import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
@@ -32,164 +33,205 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor( class ExceptionResolver private constructor(
@Assisted private val host: Host, private val host: Host,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>, 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()) { private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true) handleActivityResult(BrowserActivity.TAG, true)
} }
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) { private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it) handleActivityResult(SourceAuthActivity.TAG, it)
} }
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) { private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it) handleActivityResult(CloudFlareActivity.TAG, it)
} }
fun showErrorDetails(e: Throwable, url: String? = null) { fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url) host.router.showErrorDialog(e, url)
} }
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
is CloudFlareProtectedException -> resolveCF(e) when (e) {
is AuthRequiredException -> resolveAuthException(e.source) is CloudFlareProtectedException -> resolveCF(e)
is SSLException, is AuthRequiredException -> resolveAuthException(e.source)
is CertPathValidatorException -> { is SSLException,
showSslErrorDialog() is CertPathValidatorException -> {
false showSslErrorDialog()
} false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e) is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> { is ProxyConfigException -> {
host.router()?.openProxySettings() host.router.openProxySettings()
false false
} }
is NotFoundException -> { is NotFoundException -> {
openInBrowser(e.url) openInBrowser(e.url)
false false
} }
is UnsupportedSourceException -> { is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) } e.manga?.let { openAlternatives(it) }
false false
} }
is ScrobblerAuthRequiredException -> { is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get() val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) { if (authHelper.isAuthorized(e.scrobbler)) {
true true
} else { } else {
host.withContext { host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails) authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
} }
false false
} }
} }
else -> false else -> false
} }
}.await()
private suspend fun resolveBrowserAction( private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont -> ): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e) browserActionContract.launch(e)
} }
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont -> private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e) cloudflareContract.launch(e)
} }
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source) sourceAuthContract.launch(source)
} }
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null) host.router.openBrowser(url, null, null)
} }
private fun openAlternatives(manga: Manga) { private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga) host.router.openAlternatives(manga)
} }
private fun handleActivityResult(tag: String, result: Boolean) { private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result) continuations.remove(tag)?.resume(result)
} }
private fun showSslErrorDialog() { private fun showSslErrorDialog() {
val ctx = host.getContext() ?: return val ctx = host.context ?: return
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return return
} }
buildAlertDialog(ctx) { buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors) setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary) setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ -> setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication() ctx.restartApplication()
} }
setNegativeButton(android.R.string.cancel, null) setNegativeButton(android.R.string.cancel, null)
}.show() }.show()
} }
private inline fun Host.withContext(block: Context.() -> Unit) { class Factory @Inject constructor(
getContext()?.apply(block) private val settings: AppSettings,
} private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private fun Host.router(): AppRouter? = when (this) { fun create(fragment: Fragment) = ExceptionResolver(
is FragmentActivity -> router host = Host.FragmentHost(fragment),
is Fragment -> router settings = settings,
else -> null scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
} )
interface Host : ActivityResultCaller { fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
}
fun getChildFragmentManager(): FragmentManager private sealed interface Host : ActivityResultCaller, LifecycleOwner {
fun getContext(): Context? val context: Context?
}
@AssistedFactory val router: AppRouter
interface Factory {
fun create(host: Host): ExceptionResolver val fragmentManager: FragmentManager
}
companion object { inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
}
@StringRes class ActivityHost(val activity: FragmentActivity) : Host,
fun getResolveStringId(e: Throwable) = when (e) { ActivityResultCaller by activity,
is CloudFlareProtectedException -> R.string.captcha_solve LifecycleOwner by activity {
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0 override val context: Context
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 get() = activity
is SSLException,
is CertPathValidatorException -> R.string.fix
is ProxyConfigException -> R.string.settings override val router: AppRouter
get() = activity.router
is InteractiveActionRequiredException -> R.string._continue override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
}
else -> 0 class FragmentHost(val fragment: Fragment) : Host,
} ActivityResultCaller by fragment {
fun canResolve(e: Throwable) = getResolveStringId(e) != 0 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
else -> 0
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
} }

View File

@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer { ScreenshotPolicyHelper.ContentContainer {
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) = throw UnsupportedOperationException() override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) { protected fun setContentView(binding: B) {
this.viewBinding = binding this.viewBinding = binding
super.setContentView(binding.root) super.setContentView(binding.root)

View File

@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
abstract class BaseFragment<B : ViewBinding> : abstract class BaseFragment<B : ViewBinding> :
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
Fragment(), Fragment() {
ExceptionResolver.Host {
var viewBinding: B? = null var viewBinding: B? = null
private set private set

View File

@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
RecyclerViewOwner, RecyclerViewOwner {
ExceptionResolver.Host {
protected lateinit var exceptionResolver: ExceptionResolver protected lateinit var exceptionResolver: ExceptionResolver
private set private set

View File

@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener {
ExceptionResolver.Host {
private var waitingForDismissAllowingStateLoss = false private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false private var isFitToContentsDisabled = false