From f921ec07a6e81ef85ffac2ef09d043fa8eb75d6a Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:56:02 +0600 Subject: [PATCH] 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. --- app/src/main/java/eu/kanade/tachiyomi/App.kt | 8 +++-- .../tachiyomi/data/download/DownloadCache.kt | 5 ++- .../data/download/DownloadManager.kt | 7 ++-- .../tachiyomi/data/download/Downloader.kt | 12 +++---- .../data/library/LibraryUpdateNotifier.kt | 5 ++- .../data/notification/NotificationReceiver.kt | 28 ++++++++++++--- .../tachiyomi/extension/ExtensionManager.kt | 7 ++-- .../util/ExtensionInstallReceiver.kt | 9 ++--- .../extension/util/ExtensionInstaller.kt | 4 +-- .../tachiyomi/source/AndroidSourceManager.kt | 6 ++-- .../tachiyomi/ui/reader/ReaderViewModel.kt | 13 ++++++- .../ui/reader/loader/ChapterLoader.kt | 4 ++- .../ui/reader/loader/HttpPageLoader.kt | 3 +- app/src/main/java/mihon/app/di/AppBindings.kt | 9 +++++ .../kanade/tachiyomi/network/NetworkHelper.kt | 5 ++- .../interceptor/CloudflareInterceptor.kt | 4 ++- .../network/interceptor/WebViewInterceptor.kt | 4 ++- .../common/util/lang/CoroutinesExtensions.kt | 36 ------------------- .../mihon/core/metro/AppCoroutineScope.kt | 21 +++++++++++ domain/build.gradle.kts | 1 + .../domain/storage/service/StorageManager.kt | 5 ++- .../presentation/widget/WidgetManager.kt | 4 +-- 22 files changed, 118 insertions(+), 82 deletions(-) create mode 100644 core/metro/src/main/kotlin/mihon/core/metro/AppCoroutineScope.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index b74c6e70a..4e4ef4728 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -13,7 +13,6 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.memory.MemoryCache @@ -46,6 +45,7 @@ import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.isDebugBuildType import eu.kanade.tachiyomi.util.system.notify +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -54,6 +54,7 @@ import logcat.LogPriority import logcat.LogcatLogger import mihon.app.di.AppGraph import mihon.app.di.injekt.MetroInjektRegistrar +import mihon.core.metro.AppCoroutineScope import mihon.core.metro.GraphProvider import mihon.core.migration.Migration import mihon.core.migration.Migrator @@ -97,6 +98,9 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor @Inject private lateinit var migrations: Set + @Inject @AppCoroutineScope + private lateinit var scope: CoroutineScope + private val disableIncognitoReceiver = DisableIncognitoReceiver() @SuppressLint("LaunchActivityFromNotification") @@ -126,8 +130,6 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor ProcessLifecycleOwner.get().lifecycle.addObserver(this) - val scope = ProcessLifecycleOwner.get().lifecycleScope - // Show notification to disable Incognito Mode when it's enabled basePreferences.incognitoMode.changes() .onEach { enabled -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index 9ce4ecd3e..ae4dc458a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -11,7 +11,6 @@ import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.source.Source import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -41,6 +40,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.protobuf.ProtoBuf import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.nameWithoutExtension import tachiyomi.core.common.util.lang.launchIO @@ -65,14 +65,13 @@ import kotlin.time.Duration.Companion.seconds @Inject @SingleIn(AppScope::class) class DownloadCache( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, private val provider: DownloadProvider, private val sourceManager: SourceManager, private val storageManager: StorageManager, ) { - private val scope = CoroutineScope(Dispatchers.IO) - private val _changes: Channel = Channel(Channel.UNLIMITED) val changes = _changes.receiveAsFlow() .onStart { emit(Unit) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 24acb2458..3563e40de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.drop @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.runBlocking import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.extension import tachiyomi.core.common.util.lang.launchIO @@ -40,6 +42,7 @@ import tachiyomi.i18n.MR @Inject @SingleIn(AppScope::class) class DownloadManager( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, private val provider: DownloadProvider, private val cache: DownloadCache, @@ -253,7 +256,7 @@ class DownloadManager( * @param source the source of the chapters. */ fun deleteChapters(chapters: List, manga: Manga, source: Source) { - launchIO { + scope.launchIO { val filteredChapters = getChaptersToDelete(chapters, manga) if (filteredChapters.isEmpty()) { return@launchIO @@ -280,7 +283,7 @@ class DownloadManager( * @param removeQueued whether to also remove queued downloads. */ fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) { - launchIO { + scope.launchIO { if (removeQueued) { downloader.removeFromQueue(manga) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 94de0860e..5a34f4806 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -24,8 +24,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -47,11 +45,11 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.core.archive.ZipWriter +import mihon.core.metro.AppCoroutineScope import nl.adaptivity.xmlutil.serialization.XML import okhttp3.Response import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchNow import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE @@ -76,6 +74,7 @@ import kotlin.time.Duration.Companion.seconds @Inject @SingleIn(AppScope::class) class Downloader( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, private val provider: DownloadProvider, private val cache: DownloadCache, @@ -94,8 +93,6 @@ class Downloader( private val _queueState = MutableStateFlow>(emptyList()) val queueState = _queueState.asStateFlow() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - // Kept when canceled, so the next job can wait for it to finish private var downloaderJob: Job? = null @@ -112,9 +109,8 @@ class Downloader( var isPaused: Boolean = false init { - launchNow { - val chapters = async { store.restore() } - addAllToQueue(chapters.await()) + scope.launch { + addAllToQueue(store.restore()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index 287250e09..0e8940311 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -29,6 +29,8 @@ import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.getBitmapOrNull import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notify +import kotlinx.coroutines.CoroutineScope +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.Constants import tachiyomi.core.common.i18n.pluralStringResource import tachiyomi.core.common.i18n.stringResource @@ -44,6 +46,7 @@ import mihon.icons.materialsymbols.R as MaterialSymbolsR @Inject class LibraryUpdateNotifier( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, private val securityPreferences: SecurityPreferences, private val sourceManager: SourceManager, @@ -213,7 +216,7 @@ class LibraryUpdateNotifier( // Per-manga notification if (!securityPreferences.hideNotificationContent.get()) { - launchUI { + scope.launchUI { context.notify( updates.map { (manga, chapters) -> NotificationManagerCompat.NotificationWithIdAndTag( diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 6ce865e30..552b2d47f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -18,10 +18,12 @@ import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import mihon.app.di.appGraph +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.Constants -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.model.Chapter @@ -52,6 +54,9 @@ class NotificationReceiver : BroadcastReceiver() { @Inject private lateinit var sourceManager: SourceManager + @Inject @AppCoroutineScope + private lateinit var scope: CoroutineScope + override fun onReceive(context: Context, intent: Intent) { context.appGraph.inject(this) @@ -189,7 +194,7 @@ class NotificationReceiver : BroadcastReceiver() { * @param mangaId id of manga */ private fun markAsRead(chapterUrls: Array, mangaId: Long) { - launchIO { + launchAsync { val toUpdate = chapterUrls.mapNotNull { getChapter.await(it, mangaId) } .map { if (downloadPreferences.removeAfterMarkedAsRead.get()) { @@ -214,13 +219,28 @@ class NotificationReceiver : BroadcastReceiver() { * @param mangaId id of manga */ private fun downloadChapters(chapterUrls: Array, mangaId: Long) { - launchIO { - val manga = getManga.await(mangaId) ?: return@launchIO + launchAsync { + val manga = getManga.await(mangaId) ?: return@launchAsync val chapters = chapterUrls.mapNotNull { getChapter.await(it, mangaId) } downloadManager.downloadChapters(manga, chapters) } } + /** + * Runs [block] on the application scope while keeping the broadcast alive, so the process isn't torn down before + * the work finishes. + */ + private fun launchAsync(block: suspend CoroutineScope.() -> Unit) { + val pendingResult = goAsync() + scope.launch { + try { + block() + } finally { + pendingResult.finish() + } + } + } + companion object { private const val NAME = "NotificationReceiver" diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 5b126a5e0..d8b699e2d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -33,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.repository.ExtensionStoreRepository @@ -53,6 +53,7 @@ import java.util.Locale @Inject @SingleIn(AppScope::class) class ExtensionManager( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, private val preferences: SourcePreferences, private val trustExtension: TrustExtension, @@ -62,8 +63,6 @@ class ExtensionManager( private val extensionUpdateNotifier: ExtensionUpdateNotifier, ) { - val scope = CoroutineScope(SupervisorJob()) - private val initialized = CompletableDeferred() private val iconMap = mutableMapOf() @@ -96,7 +95,7 @@ class ExtensionManager( init { scope.launch(Dispatchers.IO) { loadExtensions() - ExtensionInstallReceiver(InstallationListener()).register(context) + ExtensionInstallReceiver(InstallationListener(), scope).register(context) // Everything the load decision rests on can change while running, so decide again merge( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt index 5a6604c01..7c3d27c14 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -9,7 +9,6 @@ import androidx.core.net.toUri import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.model.Extension import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.util.system.logcat @@ -19,10 +18,12 @@ import tachiyomi.core.common.util.system.logcat * notifies the given [listener] when the package is an extension. * * @param listener The listener that should be notified of extension installation events. + * @param scope The scope the listener is notified in. */ -internal class ExtensionInstallReceiver(private val listener: Listener) : BroadcastReceiver() { - - val scope = CoroutineScope(SupervisorJob()) +internal class ExtensionInstallReceiver( + private val listener: Listener, + private val scope: CoroutineScope, +) : BroadcastReceiver() { fun register(context: Context) { ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index 453fd2223..ef1a819ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -14,7 +14,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.isPackageInstalled import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import okhttp3.OkHttpClient import okhttp3.Request import tachiyomi.core.common.util.system.logcat @@ -34,12 +34,12 @@ import java.io.File */ @Inject class ExtensionInstaller( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, basePreferences: BasePreferences, networkHelper: NetworkHelper, ) { - private val scope = CoroutineScope(Dispatchers.IO) private val activeJobs = mutableMapOf() private val activeSteps = mutableMapOf>() private val extensionInstaller = basePreferences.extensionInstaller diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt index c1aaaf273..bf2d3b08d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt @@ -8,8 +8,6 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.online.HttpSource import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest @@ -17,6 +15,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.repository.StubSourceRepository import tachiyomi.domain.source.service.SourceManager @@ -27,14 +26,13 @@ import java.util.concurrent.ConcurrentHashMap @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class AndroidSourceManager( + @AppCoroutineScope private val scope: CoroutineScope, private val extensionManager: ExtensionManager, private val sourceRepository: StubSourceRepository, private val localSource: LocalSource, private val downloadManager: Lazy, ) : SourceManager { - private val scope = CoroutineScope(Job() + Dispatchers.IO) - /** * Null until the extensions have loaded, so that nothing observes the empty seed value. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 95f5e39e0..34802f989 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -54,6 +54,7 @@ import eu.kanade.tachiyomi.util.lang.byteSize import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.cacheImageDir import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -68,6 +69,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.toggle import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNonCancellable @@ -98,6 +100,7 @@ import kotlin.time.Instant @AssistedInject class ReaderViewModel( @Assisted private val savedState: SavedStateHandle, + @AppCoroutineScope private val appScope: CoroutineScope, private val context: Context, private val sourceManager: SourceManager, private val downloadManager: DownloadManager, @@ -332,7 +335,15 @@ class ReaderViewModel( mutableState.update { it.copy(manga = manga, source = source) } if (chapterId == -1L) chapterId = initialChapterId - loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source) + loader = ChapterLoader( + context, + downloadManager, + downloadProvider, + chapterCache, + appScope, + manga, + source, + ) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) } catch (e: Throwable) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 5fd1cc519..519a9d3da 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter +import kotlinx.coroutines.CoroutineScope import mihon.core.archive.archiveReader import mihon.core.archive.epubReader import tachiyomi.core.common.i18n.stringResource @@ -27,6 +28,7 @@ class ChapterLoader( private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, private val chapterCache: ChapterCache, + private val appScope: CoroutineScope, private val manga: Manga, private val source: Source, ) { @@ -102,7 +104,7 @@ class ChapterLoader( is Format.Epub -> EpubPageLoader(format.file.epubReader(context)) } } - source is HttpSource -> HttpPageLoader(chapter, source, chapterCache) + source is HttpSource -> HttpPageLoader(chapter, source, chapterCache, appScope) source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString())) else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index 1dfdaa0d1..9df554339 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -30,6 +30,7 @@ internal class HttpPageLoader( private val chapter: ReaderChapter, private val source: HttpSource, private val chapterCache: ChapterCache, + private val appScope: CoroutineScope, ) : PageLoader() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -132,7 +133,7 @@ internal class HttpPageLoader( // Cache current page list progress for online chapters to allow a faster reopen chapter.pages?.let { pages -> - launchIO { + appScope.launchIO { try { // Convert to pages without reader information val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 9274a6580..f6a9751b1 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -4,8 +4,12 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.serialization.json.Json import kotlinx.serialization.protobuf.ProtoBuf +import mihon.core.metro.AppCoroutineScope import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML @@ -13,6 +17,11 @@ import nl.adaptivity.xmlutil.serialization.XML @BindingContainer object AppBindings { + @Provides + @SingleIn(AppScope::class) + @AppCoroutineScope + fun providesAppCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides @SingleIn(AppScope::class) fun providesJson(): Json = Json { diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index 21357b1cc..8eea836e7 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -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() diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index bc43fb52e..7cc97fb01 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -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) diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt index 547459d72..3187025a5 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt @@ -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 diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt index a76bff2cc..07421fc25 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt @@ -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) diff --git a/core/metro/src/main/kotlin/mihon/core/metro/AppCoroutineScope.kt b/core/metro/src/main/kotlin/mihon/core/metro/AppCoroutineScope.kt new file mode 100644 index 000000000..678e56919 --- /dev/null +++ b/core/metro/src/main/kotlin/mihon/core/metro/AppCoroutineScope.kt @@ -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 diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 005fe14b0..4bb1f3047 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { dependencies { implementation(projects.sourceApi) implementation(projects.core.common) + implementation(projects.core.metro) implementation(libs.metro.runtime) implementation(libs.bundles.kotlinx.coroutines) diff --git a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt index 457013e1f..a7b2d97e7 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt @@ -8,7 +8,6 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.util.storage.DiskUtil import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -17,16 +16,16 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn +import mihon.core.metro.AppCoroutineScope @Inject @SingleIn(AppScope::class) class StorageManager( + @AppCoroutineScope private val scope: CoroutineScope, private val context: Context, storagePreferences: StoragePreferences, ) { - private val scope = CoroutineScope(Dispatchers.IO) - private var baseDir: UniFile? = getBaseDir(storagePreferences.baseStorageDirectory.get()) private val _changes: Channel = Channel(Channel.UNLIMITED) diff --git a/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt b/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt index 442d92497..e9aebcb2b 100644 --- a/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt +++ b/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt @@ -2,9 +2,9 @@ package tachiyomi.presentation.widget import android.content.Context import androidx.glance.appwidget.updateAll -import androidx.lifecycle.LifecycleCoroutineScope import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.core.security.SecurityPreferences +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -22,7 +22,7 @@ class WidgetManager( ) { context(context: Context) - fun init(scope: LifecycleCoroutineScope) { + fun init(scope: CoroutineScope) { combine( getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit), securityPreferences.useAuthenticator.changes(),