Use an app scoped CoroutineScope

Replaces the eight ad hoc application lifetime scopes and the GlobalScope
launchUI/launchIO/launchNow helpers with a single injected
CoroutineScope(SupervisorJob() + Dispatchers.IO), qualified by
@AppCoroutineScope.

Every scope this replaces was already on IO, or named IO explicitly, so
call sites keep their dispatcher. The two that need the main thread use
scope.launchUI. Scopes with a real cancellation point keep their own:
HttpPageLoader, ShizukuInstaller and the reader viewers. Only
HttpPageLoader's page list cache save moves to the app scope, since it
runs after recycle() has cancelled the loader's own scope.

NotificationReceiver now holds the broadcast open with goAsync() while
its work runs, so the process isn't torn down mid update.

This reverts the revert of #3403. It was reverted for #3429, where
restore() dropped every download because SourceManager.get() returned
null until the extensions had loaded. That surface suspends since #3869,
so the race is gone.
This commit is contained in:
AntsyLich
2026-10-10 17:37:45 +06:00
parent e21414b229
commit f921ec07a6
22 changed files with 118 additions and 82 deletions
@@ -7,6 +7,8 @@ import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import kotlinx.coroutines.CoroutineScope
import mihon.core.metro.AppCoroutineScope
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -17,6 +19,7 @@ import kotlin.time.Duration.Companion.seconds
@Inject
@SingleIn(AppScope::class)
class NetworkHelper(
@AppCoroutineScope private val scope: CoroutineScope,
private val context: Context,
private val preferences: NetworkPreferences,
) {
@@ -64,7 +67,7 @@ class NetworkHelper(
val client = clientBuilder
.addInterceptor(
CloudflareInterceptor(context, cookieJar, ::defaultUserAgentProvider),
CloudflareInterceptor(context, cookieJar, scope, ::defaultUserAgentProvider),
)
.build()
@@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.util.system.isOutdated
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
@@ -25,8 +26,9 @@ import java.util.concurrent.CountDownLatch
class CloudflareInterceptor(
private val context: Context,
private val cookieManager: AndroidCookieJar,
scope: CoroutineScope,
defaultUserAgentProvider: () -> String,
) : WebViewInterceptor(context, defaultUserAgentProvider) {
) : WebViewInterceptor(context, scope, defaultUserAgentProvider) {
private val executor = ContextCompat.getMainExecutor(context)
@@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
@@ -22,6 +23,7 @@ import java.util.concurrent.TimeUnit
abstract class WebViewInterceptor(
private val context: Context,
private val scope: CoroutineScope,
private val defaultUserAgentProvider: () -> String,
) : Interceptor {
@@ -57,7 +59,7 @@ abstract class WebViewInterceptor(
}
if (!WebViewUtil.supportsWebView(context)) {
launchUI {
scope.launchUI {
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
}
return response
@@ -1,48 +1,12 @@
package tachiyomi.core.common.util.lang
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
/**
* Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
*
* **Possible replacements**
* - suspend function
* - custom scope like view or presenter scope
*/
@DelicateCoroutinesApi
fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.Main, block = block)
@@ -0,0 +1,21 @@
package mihon.core.metro
import dev.zacsweers.metro.Qualifier
/**
* Qualifies the single application wide [kotlinx.coroutines.CoroutineScope].
*
* It lives for as long as the process does and is backed by a [kotlinx.coroutines.SupervisorJob] on
* [kotlinx.coroutines.Dispatchers.IO], so a failure in one child never takes the others down. Use it for work that is
* owned by an application scoped component and must outlive any screen. Work that belongs to a screen, a view model or
* another cancellable lifecycle keeps its own scope.
*/
@Qualifier
@Target(
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.PROPERTY_GETTER,
)
@Retention(AnnotationRetention.RUNTIME)
annotation class AppCoroutineScope