From c46317f099e6c14c19dce69dfab9482ea8089048 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:08:49 +0600 Subject: [PATCH] Move blocking work off the main thread and fix threading races (#4068) * Defer startup work until first use * Move cover work off the main thread * Make tracker calls main-safe and token refresh thread-safe Kitsu, Shikimori and MangaBaka refresh their token under a lock like MAL, so parallel calls no longer refresh it twice. * Fix extension threading and make installs cancellable The store service read response bodies on Main, which failed with NetworkOnMainThreadException for http stores. * Move reader work off the main thread and save progress in order * Serialize the downloader and fix download threading * Stop blocking on WorkManager status queries Also removes NotificationReceiver's open chapter action: nothing sends it, and its handler used runBlocking. * Start migration jobs on the migrator's dispatcher They started undispatched, so each ran on the caller's thread until its first suspension. * Run screen and view model work on fitting dispatchers * Launch work that outlives a screen on the app scope A launch on another dispatcher is dropped if the screen closes before it starts, which dialogs that dismiss right away often do. Only the UI follow-up stays on viewModelScope. * Remove the coroutine helper extensions They hid which dispatcher and scope a call used, and the GlobalScope ones hid that it was global. --- .../chapter/interactor/SetReadStatus.kt | 12 +- .../interactor/SyncChaptersWithSource.kt | 2 +- .../download/interactor/DeleteDownload.kt | 3 +- .../eu/kanade/domain/manga/model/Manga.kt | 2 +- .../eu/kanade/domain/source/model/Source.kt | 5 +- .../domain/track/interactor/AddTracks.kt | 124 +++++------ .../domain/track/interactor/RefreshTracks.kt | 3 +- .../domain/track/interactor/TrackChapter.kt | 6 +- .../service/DelayedTrackingUpdateWorker.kt | 5 +- .../track/store/DelayedTrackingStore.kt | 2 +- .../browse/components/BrowseIcons.kt | 5 +- .../library/components/LibraryContent.kt | 5 +- .../manga/components/MangaCoverDialog.kt | 95 ++++---- .../settings/screen/SettingsAdvancedScreen.kt | 27 +-- .../settings/screen/SettingsDataScreen.kt | 39 ++-- .../settings/screen/SettingsTrackingScreen.kt | 11 +- .../more/settings/screen/about/AboutScreen.kt | 8 +- .../screen/advanced/ClearDatabaseScreen.kt | 23 +- .../screen/browse/ExtensionStoresViewModel.kt | 7 +- .../screen/data/CreateBackupScreen.kt | 19 +- .../screen/data/RestoreBackupScreen.kt | 5 +- .../more/settings/screen/data/StorageInfo.kt | 19 +- .../presentation/updates/UpdatesScreen.kt | 10 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 19 +- .../data/backup/create/BackupCreateWorker.kt | 11 +- .../backup/restore/BackupRestoreWorker.kt | 8 +- .../tachiyomi/data/cache/ChapterCache.kt | 17 +- .../kanade/tachiyomi/data/cache/CoverCache.kt | 52 ++++- .../tachiyomi/data/coil/MangaCoverKeyer.kt | 2 +- .../tachiyomi/data/download/DownloadCache.kt | 54 +++-- .../data/download/DownloadManager.kt | 156 ++++++------- .../data/download/DownloadPendingDeleter.kt | 2 +- .../data/download/DownloadProvider.kt | 32 ++- .../tachiyomi/data/download/DownloadStore.kt | 2 +- .../tachiyomi/data/download/DownloadWorker.kt | 7 +- .../tachiyomi/data/download/Downloader.kt | 179 ++++++++++----- .../data/library/LibraryUpdateNotifier.kt | 24 +- .../data/library/LibraryUpdateWorker.kt | 17 +- .../data/library/MetadataUpdateWorker.kt | 7 +- .../data/notification/NotificationReceiver.kt | 41 +--- .../tachiyomi/data/track/BaseTracker.kt | 10 +- .../data/track/bangumi/BangumiApi.kt | 21 +- .../data/track/bangumi/BangumiInterceptor.kt | 23 +- .../tachiyomi/data/track/hikka/HikkaApi.kt | 21 +- .../data/track/hikka/HikkaInterceptor.kt | 48 ++-- .../tachiyomi/data/track/kavita/KavitaApi.kt | 7 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 4 +- .../data/track/kitsu/KitsuInterceptor.kt | 22 +- .../tachiyomi/data/track/komga/KomgaApi.kt | 7 +- .../data/track/mangabaka/MangaBakaApi.kt | 25 ++- .../track/mangabaka/MangaBakaInterceptor.kt | 19 +- .../track/mangaupdates/MangaUpdatesApi.kt | 89 ++++---- .../data/track/myanimelist/MyAnimeListApi.kt | 23 +- .../data/track/shikimori/ShikimoriApi.kt | 12 +- .../track/shikimori/ShikimoriInterceptor.kt | 22 +- .../tachiyomi/data/track/suwayomi/Suwayomi.kt | 2 +- .../data/track/suwayomi/SuwayomiApi.kt | 40 ++-- .../data/updater/AppUpdateChecker.kt | 5 +- .../tachiyomi/extension/ExtensionManager.kt | 63 +++--- .../installer/PackageInstallerInstaller.kt | 74 +++--- .../extension/installer/ShizukuInstaller.kt | 17 +- .../util/ExtensionInstallReceiver.kt | 77 ++++--- .../extension/util/ExtensionInstaller.kt | 40 ++-- .../extension/util/ExtensionLoader.kt | 12 +- .../extension/ExtensionFilterViewModel.kt | 3 + .../browse/extension/ExtensionsViewModel.kt | 13 +- .../details/SourcePreferencesScreen.kt | 5 +- .../migration/manga/MigrateMangaViewModel.kt | 2 +- .../browse/source/SourcesFilterViewModel.kt | 3 + .../source/browse/BrowseSourceScreen.kt | 5 +- .../source/browse/BrowseSourceViewModel.kt | 11 +- .../source/globalsearch/SearchViewModel.kt | 7 +- .../ui/category/CategoryViewModel.kt | 7 +- .../ui/deeplink/DeepLinkViewModel.kt | 5 +- .../ui/download/DownloadQueueScreen.kt | 7 +- .../ui/download/DownloadQueueViewModel.kt | 11 +- .../tachiyomi/ui/history/HistoryViewModel.kt | 37 +-- .../ui/library/LibrarySettingsViewModel.kt | 4 +- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 9 +- .../tachiyomi/ui/library/LibraryViewModel.kt | 24 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 4 +- .../tachiyomi/ui/manga/MangaCoverViewModel.kt | 14 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 5 +- .../tachiyomi/ui/manga/MangaViewModel.kt | 162 +++++++------- .../ui/manga/notes/MangaNotesScreen.kt | 9 +- .../ui/manga/track/TrackInfoDialog.kt | 43 ++-- .../eu/kanade/tachiyomi/ui/more/MoreTab.kt | 4 +- .../tachiyomi/ui/more/NewUpdateScreenModel.kt | 5 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 11 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 210 ++++++++++-------- .../ui/reader/loader/ChapterLoader.kt | 7 +- .../ui/reader/loader/DownloadPageLoader.kt | 2 +- .../ui/reader/loader/HttpPageLoader.kt | 70 ++++-- .../ui/reader/viewer/ReaderTransitionView.kt | 29 ++- .../ui/reader/viewer/pager/PagerPageHolder.kt | 17 +- .../ui/reader/viewer/webgpu/WebGpuViewer.kt | 2 +- .../viewer/webtoon/WebtoonPageHolder.kt | 17 +- .../tachiyomi/ui/stats/StatsViewModel.kt | 5 +- .../tachiyomi/ui/updates/UpdatesViewModel.kt | 32 +-- .../tachiyomi/ui/webview/WebViewActivity.kt | 14 +- .../tachiyomi/ui/webview/WebViewViewModel.kt | 3 +- .../eu/kanade/tachiyomi/util/CrashLogUtil.kt | 10 +- .../util/system/WorkManagerExtensions.kt | 5 +- .../core/migration/MigrationJobFactory.kt | 3 +- .../CategoryPreferencesCleanupMigration.kt | 7 +- .../TrustExtensionRepositoryMigration.kt | 7 +- .../migrations/VerticalNavigatorMigration.kt | 7 +- .../migration/usecases/MigrateMangaUseCase.kt | 11 +- .../interactor/UpdateMangaFromRemote.kt | 21 +- .../migration/config/MigrationConfigScreen.kt | 5 +- .../migration/dialog/MigrateMangaDialog.kt | 13 +- .../migration/list/MigrationListViewModel.kt | 25 ++- .../list/search/BaseSmartSearchEngine.kt | 2 +- .../list/search/SmartSourceSearchEngine.kt | 5 +- .../feature/upcoming/UpcomingViewModel.kt | 2 +- .../tachiyomi/network/AndroidCookieJar.kt | 2 +- .../tachiyomi/network/JavaScriptEngine.kt | 5 +- .../kanade/tachiyomi/network/NetworkHelper.kt | 16 +- .../network/interceptor/WebViewInterceptor.kt | 5 +- .../common/util/lang/CoroutinesExtensions.kt | 30 --- .../service/ExtensionStoreService.kt | 12 +- .../data/release/ReleaseServiceImpl.kt | 14 +- .../data/source/SourcePagingSource.kt | 5 +- .../interactor/CreateCategoryWithName.kt | 5 +- .../category/interactor/DeleteCategory.kt | 7 +- .../category/interactor/RenameCategory.kt | 5 +- .../category/interactor/ReorderCategory.kt | 33 ++- .../interactor/SetMangaDefaultChapterFlags.kt | 9 +- .../domain/storage/service/StorageManager.kt | 26 ++- .../widget/BaseUpdatesGridGlanceWidget.kt | 8 +- .../tachiyomi/source/local/LocalSource.kt | 13 +- 131 files changed, 1612 insertions(+), 1274 deletions(-) delete mode 100644 core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SetReadStatus.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SetReadStatus.kt index 39da3e729..9a31fb62e 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SetReadStatus.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SetReadStatus.kt @@ -3,7 +3,6 @@ package eu.kanade.domain.chapter.interactor import dev.zacsweers.metro.Inject import eu.kanade.domain.download.interactor.DeleteDownload import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.ChapterUpdate @@ -27,7 +26,7 @@ class SetReadStatus( } } - suspend fun await(read: Boolean, vararg chapters: Chapter): Result = withNonCancellableContext { + suspend fun await(read: Boolean, vararg chapters: Chapter): Result { val chaptersToUpdate = chapters.filter { when (read) { true -> !it.read @@ -35,7 +34,7 @@ class SetReadStatus( } } if (chaptersToUpdate.isEmpty()) { - return@withNonCancellableContext Result.NoChapters + return Result.NoChapters } try { @@ -44,7 +43,7 @@ class SetReadStatus( ) } catch (e: Exception) { logcat(LogPriority.ERROR, e) - return@withNonCancellableContext Result.InternalError(e) + return Result.InternalError(e) } if (read && downloadPreferences.removeAfterMarkedAsRead.get()) { @@ -61,17 +60,16 @@ class SetReadStatus( } } - Result.Success + return Result.Success } - suspend fun await(mangaId: Long, read: Boolean): Result = withNonCancellableContext { + suspend fun await(mangaId: Long, read: Boolean): Result = await( read = read, chapters = chapterRepository .getChapterByMangaId(mangaId) .toTypedArray(), ) - } suspend fun await(manga: Manga, read: Boolean) = await(manga.id, read) diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index d0215f38e..2c29786c9 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -151,7 +151,7 @@ class SyncChaptersWithSource( // The source no longer lists these, so their queued downloads could only fail removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) } .takeIf { it.isNotEmpty() } - ?.let(downloadManager::cancelQueuedDownloads) + ?.let { downloadManager.cancelQueuedDownloads(it) } // Return if there's nothing to add, delete, or update to avoid unnecessary db transactions. if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) { diff --git a/app/src/main/java/eu/kanade/domain/download/interactor/DeleteDownload.kt b/app/src/main/java/eu/kanade/domain/download/interactor/DeleteDownload.kt index 14fab2a20..6ce5e5a62 100644 --- a/app/src/main/java/eu/kanade/domain/download/interactor/DeleteDownload.kt +++ b/app/src/main/java/eu/kanade/domain/download/interactor/DeleteDownload.kt @@ -2,7 +2,6 @@ package eu.kanade.domain.download.interactor import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.data.download.DownloadManager -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager @@ -13,7 +12,7 @@ class DeleteDownload( private val downloadManager: DownloadManager, ) { - suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) = withNonCancellableContext { + suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) { sourceManager.get(manga.source)?.let { source -> downloadManager.deleteChapters(chapters.toList(), manga, source) } diff --git a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt index 33b89187b..b88737377 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt @@ -76,7 +76,7 @@ fun Manga.copyFrom(other: SManga): Manga { } fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get().appGraph.coverCache): Boolean { - return coverCache.getCustomCoverFile(id).exists() + return coverCache.hasCustomCover(id) } /** diff --git a/app/src/main/java/eu/kanade/domain/source/model/Source.kt b/app/src/main/java/eu/kanade/domain/source/model/Source.kt index 7dcc9c4ac..e39e77ee9 100644 --- a/app/src/main/java/eu/kanade/domain/source/model/Source.kt +++ b/app/src/main/java/eu/kanade/domain/source/model/Source.kt @@ -4,13 +4,14 @@ import android.content.Context import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.app.di.appGraph -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.source.model.Source import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -suspend fun Source.icon(): ImageBitmap? = withIOContext { +suspend fun Source.icon(): ImageBitmap? = withContext(Dispatchers.IO) { Injekt.get().appGraph.extensionManager.getAppIconForSource(id) ?.toBitmap() ?.asImageBitmap() diff --git a/app/src/main/java/eu/kanade/domain/track/interactor/AddTracks.kt b/app/src/main/java/eu/kanade/domain/track/interactor/AddTracks.kt index 315286bc9..010e6522c 100644 --- a/app/src/main/java/eu/kanade/domain/track/interactor/AddTracks.kt +++ b/app/src/main/java/eu/kanade/domain/track/interactor/AddTracks.kt @@ -9,10 +9,10 @@ import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import logcat.LogPriority -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.history.interactor.GetHistory @@ -30,79 +30,75 @@ class AddTracks( ) { // TODO: update all trackers based on common data - suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withNonCancellableContext { - withIOContext { - val allChapters = getChaptersByMangaId.await(mangaId) - val hasReadChapters = allChapters.any { it.read } - tracker.bind(item, hasReadChapters) + suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withContext(Dispatchers.IO) { + val allChapters = getChaptersByMangaId.await(mangaId) + val hasReadChapters = allChapters.any { it.read } + tracker.bind(item, hasReadChapters) - var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext + var track = item.toDomainTrack(idRequired = false) ?: return@withContext - upsertTrack.await(track) + upsertTrack.await(track) - // TODO: merge into [SyncChapterProgressWithTrack]? - // Update chapter progress if newer chapters marked read locally - if (hasReadChapters) { - val latestLocalReadChapterNumber = allChapters - .sortedBy { it.chapterNumber } - .takeWhile { it.read } - .lastOrNull() - ?.chapterNumber ?: -1.0 + // TODO: merge into [SyncChapterProgressWithTrack]? + // Update chapter progress if newer chapters marked read locally + if (hasReadChapters) { + val latestLocalReadChapterNumber = allChapters + .sortedBy { it.chapterNumber } + .takeWhile { it.read } + .lastOrNull() + ?.chapterNumber ?: -1.0 - if (latestLocalReadChapterNumber > track.lastChapterRead) { - track = track.copy( - lastChapterRead = latestLocalReadChapterNumber, - ) - tracker.setRemoteLastChapterRead(track.toDbTrack(), latestLocalReadChapterNumber.toInt()) - } - - if (track.startDate == null) { - val firstReadChapterDate = getHistory.await(mangaId) - .mapNotNull { it.readAt } - .minOrNull() - - firstReadChapterDate?.let { - val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone( - TimeZone.currentSystemDefault(), - TimeZone.UTC, - ) - track = track.copy( - startDate = Instant.fromEpochMilliseconds(startDate), - ) - tracker.setRemoteStartDate(track.toDbTrack(), startDate) - } - } + if (latestLocalReadChapterNumber > track.lastChapterRead) { + track = track.copy( + lastChapterRead = latestLocalReadChapterNumber, + ) + tracker.setRemoteLastChapterRead(track.toDbTrack(), latestLocalReadChapterNumber.toInt()) } - syncChapterProgressWithTrack.await(mangaId, track, tracker) + if (track.startDate == null) { + val firstReadChapterDate = getHistory.await(mangaId) + .mapNotNull { it.readAt } + .minOrNull() + + firstReadChapterDate?.let { + val startDate = firstReadChapterDate.toEpochMilliseconds().convertEpochMillisZone( + TimeZone.currentSystemDefault(), + TimeZone.UTC, + ) + track = track.copy( + startDate = Instant.fromEpochMilliseconds(startDate), + ) + tracker.setRemoteStartDate(track.toDbTrack(), startDate) + } + } } + + syncChapterProgressWithTrack.await(mangaId, track, tracker) } - suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withNonCancellableContext { - withIOContext { - trackerManager.loggedInTrackers() - .filterIsInstance() - .filter { it.accept(source) } - .forEach { service -> - try { - service.match(manga)?.let { track -> - track.manga_id = manga.id - (service as Tracker).bind(track) - upsertTrack.await(track.toDomainTrack(idRequired = false)!!) + suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withContext(Dispatchers.IO) { + trackerManager.loggedInTrackers() + .filterIsInstance() + .filter { it.accept(source) } + .forEach { service -> + try { + service.match(manga)?.let { track -> + track.manga_id = manga.id + (service as Tracker).bind(track) + upsertTrack.await(track.toDomainTrack(idRequired = false)!!) - syncChapterProgressWithTrack.await( - manga.id, - track.toDomainTrack(idRequired = false)!!, - service, - ) - } - } catch (e: Exception) { - logcat( - LogPriority.WARN, - e, - ) { "Could not match manga: ${manga.title} with service $service" } + syncChapterProgressWithTrack.await( + manga.id, + track.toDomainTrack(idRequired = false)!!, + service, + ) } + } catch (e: Exception) { + logcat( + LogPriority.WARN, + e, + ) { "Could not match manga: ${manga.title} with service $service" } } - } + } } } diff --git a/app/src/main/java/eu/kanade/domain/track/interactor/RefreshTracks.kt b/app/src/main/java/eu/kanade/domain/track/interactor/RefreshTracks.kt index 6cda78d54..4c779b271 100644 --- a/app/src/main/java/eu/kanade/domain/track/interactor/RefreshTracks.kt +++ b/app/src/main/java/eu/kanade/domain/track/interactor/RefreshTracks.kt @@ -5,6 +5,7 @@ import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.TrackerManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope @@ -30,7 +31,7 @@ class RefreshTracks( .map { it to trackerManager.get(it.trackerId) } .filter { (_, service) -> service?.isLoggedIn == true } .map { (track, service) -> - async { + async(Dispatchers.IO) { return@async try { val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!! upsertTrack.await(updatedTrack) diff --git a/app/src/main/java/eu/kanade/domain/track/interactor/TrackChapter.kt b/app/src/main/java/eu/kanade/domain/track/interactor/TrackChapter.kt index ff4aeeb4a..0f14f2879 100644 --- a/app/src/main/java/eu/kanade/domain/track/interactor/TrackChapter.kt +++ b/app/src/main/java/eu/kanade/domain/track/interactor/TrackChapter.kt @@ -9,8 +9,8 @@ import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.tachiyomi.data.track.TrackerManager import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.UpsertTrack @@ -24,9 +24,9 @@ class TrackChapter( ) { suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) { - withNonCancellableContext { + coroutineScope { val tracks = getTracks.await(mangaId) - if (tracks.isEmpty()) return@withNonCancellableContext + if (tracks.isEmpty()) return@coroutineScope tracks.mapNotNull { track -> val service = trackerManager.get(track.trackerId) diff --git a/app/src/main/java/eu/kanade/domain/track/service/DelayedTrackingUpdateWorker.kt b/app/src/main/java/eu/kanade/domain/track/service/DelayedTrackingUpdateWorker.kt index a4f29da92..808da14cc 100644 --- a/app/src/main/java/eu/kanade/domain/track/service/DelayedTrackingUpdateWorker.kt +++ b/app/src/main/java/eu/kanade/domain/track/service/DelayedTrackingUpdateWorker.kt @@ -12,10 +12,11 @@ import dev.zacsweers.metro.Inject import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.AppGraph import mihon.core.metro.metroGraph -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.track.interactor.GetTracks import java.util.concurrent.TimeUnit @@ -38,7 +39,7 @@ class DelayedTrackingUpdateWorker(private val context: Context, workerParams: Wo return Result.failure() } - withIOContext { + withContext(Dispatchers.IO) { delayedTrackingStore.getItems() .mapNotNull { val track = getTracks.awaitOne(it.trackId) diff --git a/app/src/main/java/eu/kanade/domain/track/store/DelayedTrackingStore.kt b/app/src/main/java/eu/kanade/domain/track/store/DelayedTrackingStore.kt index e8788f851..6b8aec087 100644 --- a/app/src/main/java/eu/kanade/domain/track/store/DelayedTrackingStore.kt +++ b/app/src/main/java/eu/kanade/domain/track/store/DelayedTrackingStore.kt @@ -15,7 +15,7 @@ class DelayedTrackingStore(context: Context) { /** * Preference file where queued tracking updates are stored. */ - private val preferences = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) + private val preferences by lazy { context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) } fun add(trackId: Long, lastChapterRead: Double) { val previousLastChapterRead = preferences.getFloat(trackId.toString(), 0f) diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt index a018c233b..42a5e82af 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -28,10 +28,11 @@ import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.util.ExtensionLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.roundedfilled.Dangerous import mihon.icons.materialsymbols.roundedfilled.Warning -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.source.model.Source import tachiyomi.source.local.isLocal @@ -125,7 +126,7 @@ fun ExtensionIcon( private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State> { val context = LocalContext.current return produceState>(initialValue = Result.Loading, this) { - withIOContext { + withContext(Dispatchers.IO) { value = try { val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! val appResources = context.packageManager.getResourcesForApplication(appInfo) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt index 185c1eb55..4dd08a008 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt @@ -39,7 +39,7 @@ fun LibraryContent( onContinueReadingClicked: ((LibraryManga) -> Unit)?, onToggleSelection: (Category, LibraryManga) -> Unit, onToggleRangeSelection: (Category, LibraryManga) -> Unit, - onRefresh: () -> Boolean, + onRefresh: suspend () -> Boolean, onGlobalSearchClicked: () -> Unit, getItemCountForCategory: (Category) -> Int?, getDisplayMode: (Int) -> PreferenceMutableState, @@ -80,9 +80,8 @@ fun LibraryContent( refreshing = isRefreshing, enabled = selection.isEmpty(), onRefresh = { - val started = onRefresh() - if (!started) return@PullRefresh scope.launch { + if (!onRefresh()) return@launch // Fake refresh status but hide it after a second as it's a long running task isRefreshing = true delay(1.seconds) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index 51e35afee..ff7dc4779 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,6 +45,7 @@ import coil3.asDrawable import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest +import coil3.request.SuccessResult import coil3.size.Size import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions @@ -53,7 +55,7 @@ import eu.kanade.tachiyomi.data.coil.ImageDecoder import eu.kanade.tachiyomi.data.coil.newDecoder import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import mihon.app.di.appGraph import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Close @@ -167,54 +169,53 @@ fun MangaCoverDialog( }, ) { contentPadding -> if (useNewRenderer) { - val state = ImageViewerState() - - ImageRequest.Builder(view.context) - .data(manga) - .size(Size.ORIGINAL) - .memoryCachePolicy(CachePolicy.DISABLED) - .newDecoder(true) - .target { result -> - val res = (result as ImageDecoder.DecodeResultImage) - // Held, then freed, around the upload: its buffer alone doesn't keep - // the frame's native pixels alive. - val page = res.frame.use { - runBlocking(Dispatchers.Default) { - ImagePage.ImageSingle( - Image( - res.image, - res.width, - res.height, - createMipMaps = true, - backgroundColor = 0, - hdr = res.isHdr, - hdrHeadroom = res.hdrHeadroom, - gainmap = res.gainmap?.let { - GainmapInput( - pixels = it.pixels, - width = it.width, - height = it.height, - channels = it.channels, - gamma = it.gamma, - minContentBoost = it.minContentBoost, - maxContentBoost = it.maxContentBoost, - offsetSdr = it.offsetSdr, - offsetHdr = it.offsetHdr, - ) - }, - ), - ) - } - } - state.apply { - fetchPage = { index -> - if (index == 0) page else null - } - invalidate() + val state = remember(manga) { ImageViewerState() } + LaunchedEffect(manga) { + val request = ImageRequest.Builder(view.context) + .data(manga) + .size(Size.ORIGINAL) + .memoryCachePolicy(CachePolicy.DISABLED) + .newDecoder(true) + .build() + val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect + val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect + // Held, then freed, around the upload: its buffer alone doesn't keep + // the frame's native pixels alive. + val page = res.frame.use { + withContext(Dispatchers.Default) { + ImagePage.ImageSingle( + Image( + res.image, + res.width, + res.height, + createMipMaps = true, + backgroundColor = 0, + hdr = res.isHdr, + hdrHeadroom = res.hdrHeadroom, + gainmap = res.gainmap?.let { + GainmapInput( + pixels = it.pixels, + width = it.width, + height = it.height, + channels = it.channels, + gamma = it.gamma, + minContentBoost = it.minContentBoost, + maxContentBoost = it.maxContentBoost, + offsetSdr = it.offsetSdr, + offsetHdr = it.offsetHdr, + ) + }, + ), + ) } } - .build() - .let(view.context.imageLoader::enqueue) + state.apply { + fetchPage = { index -> + if (index == 0) page else null + } + invalidate() + } + } ImageViewer(state = state) return@Scaffold diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 2653bb810..936f20689 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -47,12 +47,12 @@ import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import okhttp3.Headers -import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.i18n.MR @@ -193,6 +193,7 @@ object SettingsAdvancedScreen : SearchableSettings { ): Preference.PreferenceGroup { val context = LocalContext.current val networkHelper = remember { context.appGraph.networkHelper } + val scope = rememberCoroutineScope() val userAgentPref = networkPreferences.defaultUserAgent val userAgent by userAgentPref.collectAsState() @@ -219,8 +220,10 @@ object SettingsAdvancedScreen : SearchableSettings { clearSslPreferences() } WebStorage.getInstance().deleteAllData() - context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } - context.toast(MR.strings.webview_data_deleted) + scope.launch(Dispatchers.IO) { + context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } + withContext(Dispatchers.Main) { context.toast(MR.strings.webview_data_deleted) } + } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) context.toast(MR.strings.cache_delete_error) @@ -289,22 +292,20 @@ object SettingsAdvancedScreen : SearchableSettings { preferenceItems = listOf( Preference.PreferenceItem.TextPreference( title = stringResource(MR.strings.pref_refresh_library_covers), - onClick = { MetadataUpdateWorker.startNow(context.workManager) }, + onClick = { scope.launch { MetadataUpdateWorker.startNow(context.workManager) } }, ), Preference.PreferenceItem.TextPreference( title = stringResource(MR.strings.pref_reset_viewer_flags), subtitle = stringResource(MR.strings.pref_reset_viewer_flags_summary), onClick = { - scope.launchNonCancellable { + scope.launch { val success = context.appGraph.resetViewerFlags.await() - withUIContext { - val message = if (success) { - MR.strings.pref_reset_viewer_flags_success - } else { - MR.strings.pref_reset_viewer_flags_error - } - context.toast(message) + val message = if (success) { + MR.strings.pref_reset_viewer_flags_success + } else { + MR.strings.pref_reset_viewer_flags_error } + context.toast(message) } }, ), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt index f184db8ac..b709e77b9 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDataScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -53,16 +54,16 @@ import eu.kanade.tachiyomi.data.export.LibraryExporter.ExportOptions import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.Help import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.displayablePath -import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.manga.model.Manga @@ -181,6 +182,7 @@ object SettingsDataScreen : SearchableSettings { private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup { val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow + val scope = rememberCoroutineScope() val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp.collectAsState() @@ -231,13 +233,15 @@ object SettingsDataScreen : SearchableSettings { modifier = Modifier.fillMaxHeight(), checked = false, onCheckedChange = { - if (!BackupRestoreWorker.isRunning(context.workManager)) { - if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { - context.toast(MR.strings.restore_miui_warning) + scope.launch { + if (!BackupRestoreWorker.isRunning(context.workManager)) { + if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { + context.toast(MR.strings.restore_miui_warning) + } + chooseBackup.launch(arrayOf("*/*")) + } else { + context.toast(MR.strings.restore_in_progress) } - chooseBackup.launch(arrayOf("*/*")) - } else { - context.toast(MR.strings.restore_in_progress) } }, shape = SegmentedButtonDefaults.itemShape(1, 2), @@ -282,7 +286,9 @@ object SettingsDataScreen : SearchableSettings { val chapterCache = remember { context.appGraph.chapterCache } var cacheReadableSizeSema by remember { mutableIntStateOf(0) } - val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize } + val cacheReadableSize by produceState("", cacheReadableSizeSema) { + value = withContext(Dispatchers.IO) { chapterCache.readableSize } + } return Preference.PreferenceGroup( title = stringResource(MR.strings.pref_storage_usage), @@ -303,16 +309,15 @@ object SettingsDataScreen : SearchableSettings { title = stringResource(MR.strings.pref_clear_chapter_cache), subtitle = stringResource(MR.strings.used_cache, cacheReadableSize), onClick = { - scope.launchNonCancellable { + scope.launch { try { - val deletedFiles = chapterCache.clear() - withUIContext { - context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles)) - cacheReadableSizeSema++ - } - } catch (e: Throwable) { + val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() } + context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles)) + cacheReadableSizeSema++ + } catch (e: Exception) { + if (e is CancellationException) throw e logcat(LogPriority.ERROR, e) - withUIContext { context.toast(MR.strings.cache_delete_error) } + context.toast(MR.strings.cache_delete_error) } } }, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index 8f9011fc7..1635aa687 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt @@ -52,14 +52,15 @@ import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mihon.app.di.appGraph import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.Help import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.roundedfilled.Visibility import mihon.icons.materialsymbols.roundedfilled.VisibilityOff -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @@ -276,7 +277,7 @@ object SettingsTrackingScreen : SearchableSettings { modifier = Modifier.fillMaxWidth(), enabled = !processing && username.text.isNotBlank() && password.text.isNotBlank(), onClick = { - scope.launchIO { + scope.launch(Dispatchers.IO) { processing = true val result = checkLogin( context = context, @@ -305,11 +306,11 @@ object SettingsTrackingScreen : SearchableSettings { ): Boolean { return try { tracker.login(username, password) - withUIContext { context.toast(MR.strings.login_success) } + withContext(Dispatchers.Main) { context.toast(MR.strings.login_success) } true } catch (e: Throwable) { tracker.logout() - withUIContext { context.toast(e.message.toString()) } + withContext(Dispatchers.Main) { context.toast(e.message.toString()) } false } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt index bf6e674ce..758c2890d 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/about/AboutScreen.kt @@ -36,7 +36,9 @@ import eu.kanade.tachiyomi.util.system.isFossBuildType import eu.kanade.tachiyomi.util.system.isNightlyBuildType import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.updaterEnabled +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import logcat.LogPriority @@ -50,8 +52,6 @@ import mihon.icons.simpleicons.Reddit import mihon.icons.simpleicons.SimpleIcons import mihon.icons.simpleicons.X import tachiyomi.core.common.Constants -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.i18n.MR @@ -218,9 +218,9 @@ object AboutScreen : Screen() { onFinish: () -> Unit, ) { val updateChecker = context.appGraph.updateChecker - withUIContext { + withContext(Dispatchers.Main) { try { - when (val result = withIOContext { updateChecker.checkForUpdate(forceCheck = true) }) { + when (val result = updateChecker.checkForUpdate(forceCheck = true)) { is GetApplicationRelease.Result.NewUpdate -> { onAvailableUpdate(result) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt index 3c15bc17e..74ba796f7 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt @@ -42,16 +42,17 @@ import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.FlipToBack import mihon.icons.materialsymbols.rounded.SelectAll -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchUI -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga @@ -117,7 +118,7 @@ class ClearDatabaseScreen : Screen() { confirmButton = { TextButton( onClick = { - scope.launchUI { + scope.launch(Dispatchers.Main) { viewModel.removeMangaBySourceId(keepReadManga) viewModel.clearSelection() viewModel.hideConfirmation() @@ -229,6 +230,7 @@ class ClearDatabaseScreen : Screen() { @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class ClearDatabaseViewModel( + @AppCoroutineScope private val appScope: CoroutineScope, private val mangaRepository: MangaRepository, private val historyRepository: HistoryRepository, private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga, @@ -238,7 +240,7 @@ class ClearDatabaseViewModel( field = MutableStateFlow(State.Loading) init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { getSourcesWithNonLibraryManga.subscribe() .collectLatest { list -> state.update { old -> @@ -252,10 +254,13 @@ class ClearDatabaseViewModel( } } - suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext { - val state = state.value as? State.Ready ?: return@withNonCancellableContext - mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga) - historyRepository.deleteResetHistory() + suspend fun removeMangaBySourceId(keepReadManga: Boolean) { + val state = state.value as? State.Ready ?: return + // Finishes even if the screen is left while it runs + appScope.launch { + mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga) + historyRepository.deleteResetHistory() + }.join() } fun toggleSelection(source: Source) = state.update { state -> diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionStoresViewModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionStoresViewModel.kt index 4cc6ccd96..b0f225c72 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionStoresViewModel.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionStoresViewModel.kt @@ -24,7 +24,6 @@ import mihon.domain.extension.interactor.GetExtensionStores import mihon.domain.extension.interactor.RemoveExtensionStore import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.model.ExtensionStore -import tachiyomi.core.common.util.lang.launchIO import kotlin.time.Duration.Companion.seconds @Inject @@ -90,7 +89,7 @@ class ExtensionStoresViewModel( * Refreshes information for each repository. */ fun refreshRepos() { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { updateExtensionStores() } } @@ -99,14 +98,14 @@ class ExtensionStoresViewModel( * Deletes the given repo from the database */ fun deleteRepo(baseUrl: String) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { removeExtensionStore(baseUrl) extensionManager.findAvailableExtensions() } } fun addFromDeeplink(storeIndexUrl: String) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val alreadyExists = getExtensionStores.get().any { it.indexUrl == storeIndexUrl } dialog.update { ExtensionStoreDialog.Confirm(url = storeIndexUrl, alreadyExists = alreadyExists) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt index 917a581bf..edb245180 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.ViewModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -31,6 +32,7 @@ import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LazyColumnWithAction @@ -46,6 +48,7 @@ class CreateBackupScreen : Screen() { val navigator = LocalNavigator.currentOrThrow val viewModel = metroViewModel() val state by viewModel.state.collectAsState() + val scope = rememberCoroutineScope() val chooseBackupDir = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/*"), @@ -75,14 +78,16 @@ class CreateBackupScreen : Screen() { actionLabel = stringResource(MR.strings.action_create), actionEnabled = state.options.canCreate(), onClickAction = { - if (!BackupCreateWorker.isManualJobRunning(context)) { - try { - chooseBackupDir.launch(BackupCreator.getFilename()) - } catch (_: ActivityNotFoundException) { - context.toast(MR.strings.file_picker_error) + scope.launch { + if (!BackupCreateWorker.isManualJobRunning(context)) { + try { + chooseBackupDir.launch(BackupCreator.getFilename()) + } catch (_: ActivityNotFoundException) { + context.toast(MR.strings.file_picker_error) + } + } else { + context.toast(MR.strings.backup_in_progress) } - } else { - context.toast(MR.strings.backup_in_progress) } }, ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt index 6899c2c4a..48276813c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/RestoreBackupScreen.kt @@ -39,10 +39,11 @@ import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreWorker import eu.kanade.tachiyomi.data.backup.restore.RestoreOptions import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import tachiyomi.core.common.util.lang.launchIO +import kotlinx.coroutines.launch import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LazyColumnWithAction @@ -193,7 +194,7 @@ class RestoreBackupViewModel( } init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { validate(uri.toUri()) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/StorageInfo.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/StorageInfo.kt index 5fed6c6ef..b53b0cbb7 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/StorageInfo.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/StorageInfo.kt @@ -9,12 +9,16 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import eu.kanade.tachiyomi.util.storage.DiskUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @@ -27,7 +31,9 @@ fun StorageInfo( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val storages = remember { DiskUtil.getExternalStorages(context) } + val storages by produceState(emptyList()) { + value = withContext(Dispatchers.IO) { DiskUtil.getExternalStorages(context) } + } Column( modifier = modifier, @@ -45,9 +51,14 @@ private fun StorageInfo( ) { val context = LocalContext.current - val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) } + val space by produceState(0L to 0L, file) { + value = + withContext(Dispatchers.IO) { + DiskUtil.getAvailableStorageSpace(file) to DiskUtil.getTotalStorageSpace(file) + } + } + val (available, total) = space val availableText = remember(available) { Formatter.formatFileSize(context, available) } - val total = remember(file) { DiskUtil.getTotalStorageSpace(file) } val totalText = remember(total) { Formatter.formatFileSize(context, total) } Column( @@ -63,7 +74,7 @@ private fun StorageInfo( .clip(MaterialTheme.shapes.small) .fillMaxWidth() .height(12.dp), - progress = { (1 - (available / total.toFloat())) }, + progress = { if (total > 0) 1 - (available / total.toFloat()) else 0f }, ) Text( diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index d4138d9f4..fa1f52fda 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -54,7 +54,7 @@ fun UpdateScreen( onSelectAll: (Boolean) -> Unit, onInvertSelection: () -> Unit, onCalendarClicked: () -> Unit, - onUpdateLibrary: () -> Boolean, + onUpdateLibrary: suspend () -> Boolean, onDownloadChapter: (List, ChapterDownloadAction) -> Unit, onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, @@ -68,11 +68,13 @@ fun UpdateScreen( onSelectAll(false) } + val scope = rememberCoroutineScope() + Scaffold( topBar = { scrollBehavior -> UpdatesAppBar( onCalendarClicked = { onCalendarClicked() }, - onUpdateLibrary = { onUpdateLibrary() }, + onUpdateLibrary = { scope.launch { onUpdateLibrary() } }, onFilterClicked = { onFilterClicked() }, hasFilters = hasActiveFilters, actionModeCounter = state.selected.size, @@ -96,15 +98,13 @@ fun UpdateScreen( when { state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) else -> { - val scope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } PullRefresh( refreshing = isRefreshing, onRefresh = { - val started = onUpdateLibrary() - if (!started) return@PullRefresh scope.launch { + if (!onUpdateLibrary()) return@launch // Fake refresh status but hide it after a second as it's a long running task isRefreshing = true delay(1.seconds) diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 4e4ef4728..1cc413a5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.preference.PreferenceManager import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.memory.MemoryCache @@ -70,6 +71,7 @@ import tachiyomi.presentation.widget.WidgetManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.InjektScope import java.security.Security +import kotlin.concurrent.thread import mihon.icons.materialsymbols.R as MaterialSymbolsR class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory, GraphProvider { @@ -88,11 +90,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor @Inject private lateinit var uiPreferences: UiPreferences - @Inject private lateinit var coverCache: CoverCache + // Lazy: only the image loader uses these, and process starts without UI (workers, receivers) never build it + @Inject private lateinit var coverCache: Lazy - @Inject private lateinit var networkHelper: NetworkHelper + @Inject private lateinit var networkHelper: Lazy - @Inject private lateinit var sourceManager: SourceManager + @Inject private lateinit var sourceManager: Lazy @Inject private lateinit var widgetManager: WidgetManager @@ -114,6 +117,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor if (packageName != process) WebView.setDataDirectorySuffix(process) } + // The graph and the code after it read the default preferences straight away. Loading them takes a disk + // read, so start it on another thread while the graph is built; the first read then waits less or not at all + thread(name = "PreferencesPreload") { PreferenceManager.getDefaultSharedPreferences(this) } + // The first use of Dispatchers.Main finds its factory with a ServiceLoader, which reads the APK + thread(name = "MainDispatcherPreload") { Dispatchers.Main } + Injekt = InjektScope(MetroInjektRegistrar(application = this, graphProvider = this)) graph.inject(this) @@ -205,7 +214,9 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor override fun newImageLoader(context: Context): ImageLoader { return ImageLoader.Builder(this).apply { - val callFactoryLazy = lazy { networkHelper.client } + val callFactoryLazy = lazy { networkHelper.value.client } + val coverCache = coverCache.value + val sourceManager = sourceManager.value components { // NetworkFetcher.Factory add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateWorker.kt index 5a56997f3..928cabc6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateWorker.kt @@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.AppGraph import mihon.app.di.appGraph @@ -53,7 +55,7 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam if (isAutoBackup && BackupRestoreWorker.isRunning(context.workManager)) return Result.retry() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() - ?: getAutomaticBackupLocation() + ?: withContext(Dispatchers.IO) { getAutomaticBackupLocation() } ?: return Result.failure() setForegroundSafely() @@ -62,7 +64,10 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam ?: BackupOptions() return try { - val location = backupCreatorFactory.create(isAutoBackup = isAutoBackup).backup(uri, options) + val location = + withContext(Dispatchers.IO) { + backupCreatorFactory.create(isAutoBackup = isAutoBackup).backup(uri, options) + } if (!isAutoBackup) { notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!) } @@ -93,7 +98,7 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam } companion object { - fun isManualJobRunning(context: Context): Boolean { + suspend fun isManualJobRunning(context: Context): Boolean { return context.workManager.isRunning(TAG_MANUAL) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreWorker.kt index 1bf8e9640..aa4db363f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreWorker.kt @@ -19,6 +19,8 @@ import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.AppGraph import mihon.core.metro.metroGraph @@ -50,7 +52,9 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara setForegroundSafely() return try { - backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options) + withContext(Dispatchers.IO) { + backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options) + } Result.success() } catch (e: Exception) { if (e is CancellationException) { @@ -79,7 +83,7 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara } companion object { - fun isRunning(workManager: WorkManager): Boolean { + suspend fun isRunning(workManager: WorkManager): Boolean { return workManager.isRunning(TAG) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt index b18631a25..9f3ec6611 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt @@ -36,17 +36,20 @@ class ChapterCache( ) { /** Cache class used for cache management. */ - private val diskCache = DiskLruCache.open( - File(context.cacheDir, "chapter_disk_cache"), - PARAMETER_APP_VERSION, - PARAMETER_VALUE_COUNT, - PARAMETER_CACHE_SIZE, - ) + private val diskCache by lazy { + DiskLruCache.open( + File(context.cacheDir, "chapter_disk_cache"), + PARAMETER_APP_VERSION, + PARAMETER_VALUE_COUNT, + PARAMETER_CACHE_SIZE, + ) + } /** * Returns directory of cache. */ - private val cacheDir: File = diskCache.directory + private val cacheDir: File + get() = diskCache.directory /** * Returns real size of directory. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt index 7ed4761c7..480db21e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -5,6 +5,9 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.util.storage.DiskUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import tachiyomi.domain.manga.model.Manga import java.io.File import java.io.IOException @@ -20,7 +23,10 @@ import java.io.InputStream */ @Inject @SingleIn(AppScope::class) -class CoverCache(private val context: Context) { +class CoverCache( + @AppCoroutineScope private val scope: CoroutineScope, + private val context: Context, +) { companion object { private const val COVERS_DIR = "covers" @@ -30,9 +36,28 @@ class CoverCache(private val context: Context) { /** * Cache directory used for cache management. */ - private val cacheDir = getCacheDir(COVERS_DIR) + private val cacheDir by lazy { getCacheDir(COVERS_DIR) } - private val customCoverCacheDir = getCacheDir(CUSTOM_COVERS_DIR) + private val customCoverCacheDir by lazy { getCacheDir(CUSTOM_COVERS_DIR) } + + private val customCoverNames = mutableSetOf() + + // Deleted while the directory was being listed, so the listing may still contain them + private val deletedWhileIndexing = mutableSetOf() + + @Volatile + private var customCoversIndexed = false + + init { + scope.launch { + val names = customCoverCacheDir.list().orEmpty() + synchronized(customCoverNames) { + names.filterNotTo(customCoverNames) { it in deletedWhileIndexing } + deletedWhileIndexing.clear() + customCoversIndexed = true + } + } + } /** * Returns the cover from cache. @@ -56,6 +81,12 @@ class CoverCache(private val context: Context) { return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(mangaId.toString())) } + fun hasCustomCover(mangaId: Long?): Boolean { + val file = getCustomCoverFile(mangaId) + if (!customCoversIndexed) return file.exists() + return synchronized(customCoverNames) { file.name in customCoverNames } + } + /** * Saves the given stream as the manga's custom cover to cache. * @@ -65,9 +96,14 @@ class CoverCache(private val context: Context) { */ @Throws(IOException::class) fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) { - getCustomCoverFile(manga.id).outputStream().use { + val file = getCustomCoverFile(manga.id) + file.outputStream().use { inputStream.copyTo(it) } + synchronized(customCoverNames) { + customCoverNames.add(file.name) + deletedWhileIndexing.remove(file.name) + } } /** @@ -98,9 +134,13 @@ class CoverCache(private val context: Context) { * @return whether the cover was deleted. */ fun deleteCustomCover(mangaId: Long?): Boolean { - return getCustomCoverFile(mangaId).let { - it.exists() && it.delete() + val file = getCustomCoverFile(mangaId) + val deleted = file.exists() && file.delete() + synchronized(customCoverNames) { + customCoverNames.remove(file.name) + if (!customCoversIndexed) deletedWhileIndexing.add(file.name) } + return deleted } private fun getCacheDir(dir: String): File { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverKeyer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverKeyer.kt index c4059127d..61a30978a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverKeyer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/MangaCoverKeyer.kt @@ -21,7 +21,7 @@ class MangaCoverKeyer( private val coverCache: CoverCache, ) : Keyer { override fun key(data: MangaCover, options: Options): String { - return if (coverCache.getCustomCoverFile(data.mangaId).exists()) { + return if (coverCache.hasCustomCover(data.mangaId)) { "${data.mangaId};${data.lastModified}" } else { "${data.url};${data.lastModified}" 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 ae4dc458a..ebb2804c8 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,6 +11,7 @@ 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 @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull @@ -43,8 +45,6 @@ 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 -import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga @@ -99,11 +99,13 @@ class DownloadCache( get() = AtomicFile(File(context.cacheDir, "dl_index_cache_v3")) private val rootDownloadsDirMutex = Mutex() - private var rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory()) + + @Volatile + private var rootDownloadsDir = RootDirectory(null) init { // Attempt to read cache file - initJob = scope.launchIO { + initJob = scope.launch(Dispatchers.IO) { rootDownloadsDirMutex.withLock { try { if (diskCacheFile.baseFile.exists()) { @@ -252,11 +254,8 @@ class DownloadCache( rootDownloadsDirMutex.withLock { val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return - provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { - if (it in mangaDir.chapterDirs) { - mangaDir.chapterDirs -= it - } - } + mangaDir.chapterDirs -= + provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).toSet() } notifyChanges() @@ -272,13 +271,9 @@ class DownloadCache( rootDownloadsDirMutex.withLock { val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return - chapters.forEach { chapter -> - provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { - if (it in mangaDir.chapterDirs) { - mangaDir.chapterDirs -= it - } - } - } + mangaDir.chapterDirs -= chapters + .flatMap { provider.getValidChapterDirNames(it.name, it.scanlator, it.url) } + .toSet() } notifyChanges() @@ -312,7 +307,7 @@ class DownloadCache( rootDownloadsDirMutex.withLock { val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val oldMangaDirName = provider.getMangaDirName(manga.title) - var oldChapterDirs: MutableSet? = null + var oldChapterDirs: Set? = null // Save the old name's cached chapter dirs if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) { oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs @@ -347,22 +342,22 @@ class DownloadCache( fun invalidateCache() { lastRenew = 0L renewalJob?.cancel() - diskCacheFile.delete() - renewCache() + renewCache(deleteDiskCache = true) } /** * Renews the downloads cache. */ - private fun renewCache() { + private fun renewCache(deleteDiskCache: Boolean = false) { // Avoid renewing cache if in the process nor too often if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { return } - renewalJob = scope.launchIO { + renewalJob = scope.launch(Dispatchers.IO) { // The disk index is older than this scan and would replace it if read afterwards initJob.join() + if (deleteDiskCache) diskCacheFile.delete() if (lastRenew == 0L) { _isInitializing.emit(true) @@ -407,7 +402,7 @@ class DownloadCache( else -> null } } - .toMutableSet() + .toSet() mangaDir.chapterDirs = chapterDirs } @@ -424,7 +419,8 @@ class DownloadCache( if (exception != null && exception !is CancellationException) { logcat(LogPriority.ERROR, exception) { "DownloadCache: failed to create cache" } } - lastRenew = System.currentTimeMillis() + // A cancelled renewal (e.g. from invalidateCache) must not hold off the one replacing it + if (exception !is CancellationException) lastRenew = System.currentTimeMillis() notifyChanges() } } @@ -438,16 +434,14 @@ class DownloadCache( } private fun notifyChanges() { - scope.launchNonCancellable { - _changes.send(Unit) - } + _changes.trySend(Unit) updateDiskCache() } private var updateDiskCacheJob: Job? = null private fun updateDiskCache() { updateDiskCacheJob?.cancel() - updateDiskCacheJob = scope.launchIO { + updateDiskCacheJob = scope.launch(Dispatchers.IO) { delay(1.seconds) val bytes = rootDownloadsDirMutex.withLock { ProtoBuf.encodeToByteArray(rootDownloadsDir) @@ -473,6 +467,7 @@ class DownloadCache( private class RootDirectory( @Serializable(with = UniFileAsStringSerializer::class) val dir: UniFile?, + @Volatile var sourceDirs: Map = mapOf(), ) @@ -483,6 +478,7 @@ private class RootDirectory( private class SourceDirectory( @Serializable(with = UniFileAsStringSerializer::class) val dir: UniFile?, + @Volatile var mangaDirs: Map = mapOf(), ) @@ -493,7 +489,9 @@ private class SourceDirectory( private class MangaDirectory( @Serializable(with = UniFileAsStringSerializer::class) val dir: UniFile?, - var chapterDirs: MutableSet = mutableSetOf(), + // Replaced rather than changed in place, so the lock-free lookups always read a consistent set + @Volatile + var chapterDirs: Set = emptySet(), ) private object UniFileAsStringSerializer : KSerializer { 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 3563e40de..2ad7fe4e4 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,7 +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.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.drop @@ -17,12 +17,11 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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 import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories @@ -42,7 +41,6 @@ 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, @@ -73,15 +71,14 @@ class DownloadManager( /** * Tells the downloader to pause downloads. */ - fun pauseDownloads() { + suspend fun pauseDownloads() { downloader.pause() - downloader.stop() } /** * Empties the download queue. */ - fun clearQueue() { + suspend fun clearQueue() { downloader.clearQueue() downloader.stop() } @@ -103,14 +100,12 @@ class DownloadManager( return queueState.value.associateBy { it.chapter.id } } - fun startDownloadNow(chapterId: Long) { - val existingDownload = getQueuedDownloadOrNull(chapterId) + suspend fun startDownloadNow(chapterId: Long) { // If not in queue try to start a new download - val toAdd = existingDownload ?: runBlocking { downloadFromChapterId(chapterId) } ?: return - queueState.value.toMutableList().apply { - existingDownload?.let { remove(it) } - add(0, toAdd) - reorderQueue(this) + val toAdd = getQueuedDownloadOrNull(chapterId) ?: downloadFromChapterId(chapterId) ?: return + downloader.updateQueue { queue -> + val (existing, others) = queue.partition { it.chapter.id == chapterId } + listOf(existing.firstOrNull() ?: toAdd) + others } startDownloads() } @@ -126,10 +121,15 @@ class DownloadManager( /** * Reorders the download queue. * - * @param downloads value to set the download queue to + * @param downloads the queue in its new order. Downloads that left the queue since are dropped, and ones + * that joined it are kept at the end. */ - fun reorderQueue(downloads: List) { - downloader.updateQueue(downloads) + suspend fun reorderQueue(downloads: List) { + downloader.updateQueue { queue -> + val queueById = queue.associateBy { it.chapter.id } + val orderedIds = downloads.mapTo(HashSet()) { it.chapter.id } + downloads.mapNotNull { queueById[it.chapter.id] } + queue.filter { it.chapter.id !in orderedIds } + } } /** @@ -150,11 +150,11 @@ class DownloadManager( * * @param downloads the list of downloads to enqueue. */ - fun addDownloadsToStartOfQueue(downloads: List) { + suspend fun addDownloadsToStartOfQueue(downloads: List) { if (downloads.isEmpty()) return - queueState.value.toMutableList().apply { - addAll(0, downloads) - reorderQueue(this) + downloader.updateQueue { queue -> + val chapterIds = downloads.mapTo(HashSet()) { it.chapter.id } + downloads + queue.filter { it.chapter.id !in chapterIds } } startDownloads() } @@ -167,10 +167,12 @@ class DownloadManager( * @param chapter the downloaded chapter. * @return the list of pages from the chapter. */ - fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List { + suspend fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List { val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source) - val files = chapterDir?.listFiles().orEmpty() - .filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } } + val files = withContext(Dispatchers.IO) { + chapterDir?.listFiles().orEmpty() + .filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } } + } if (files.isEmpty()) { throw Exception(context.stringResource(MR.strings.page_list_empty_error)) @@ -218,7 +220,7 @@ class DownloadManager( * @param mangaTitle the title of the manga to query. * @param source the source of the chapter. */ - fun isChapterDownloadedOnDisk( + suspend fun isChapterDownloadedOnDisk( chapterName: String, chapterScanlator: String?, chapterUrl: String, @@ -244,8 +246,8 @@ class DownloadManager( return cache.getDownloadCount(manga) } - fun cancelQueuedDownloads(downloads: List) { - removeFromDownloadQueue(downloads.map { it.chapter }) + suspend fun cancelQueuedDownloads(downloads: List) { + downloader.dequeue(downloads.map { it.chapter }) } /** @@ -255,14 +257,14 @@ class DownloadManager( * @param manga the manga of the chapters. * @param source the source of the chapters. */ - fun deleteChapters(chapters: List, manga: Manga, source: Source) { - scope.launchIO { + suspend fun deleteChapters(chapters: List, manga: Manga, source: Source) { + withContext(Dispatchers.IO) { val filteredChapters = getChaptersToDelete(chapters, manga) if (filteredChapters.isEmpty()) { - return@launchIO + return@withContext } - removeFromDownloadQueue(filteredChapters) + downloader.dequeue(filteredChapters) val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source) chapterDirs.forEach { it.delete() } @@ -282,8 +284,8 @@ class DownloadManager( * @param source the source of the manga. * @param removeQueued whether to also remove queued downloads. */ - fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) { - scope.launchIO { + suspend fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) { + withContext(Dispatchers.IO) { if (removeQueued) { downloader.removeFromQueue(manga) } @@ -299,23 +301,6 @@ class DownloadManager( } } - private fun removeFromDownloadQueue(chapters: List) { - val wasRunning = downloader.isRunning - if (wasRunning) { - downloader.pause() - } - - downloader.removeFromQueue(chapters) - - if (wasRunning) { - if (queueState.value.isEmpty()) { - downloader.stop() - } else if (queueState.value.isNotEmpty()) { - downloader.start() - } - } - } - /** * Adds a list of chapters to be deleted later. * @@ -343,18 +328,18 @@ class DownloadManager( * @param oldSource the old source. * @param newSource the new source. */ - fun renameSource(oldSource: Source, newSource: Source) { - val oldFolder = provider.findSourceDir(oldSource) ?: return + suspend fun renameSource(oldSource: Source, newSource: Source) = withContext(Dispatchers.IO) { + val oldFolder = provider.findSourceDir(oldSource) ?: return@withContext val newName = provider.getSourceDirName(newSource) - if (oldFolder.name == newName) return + if (oldFolder.name == newName) return@withContext val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true) if (capitalizationChanged) { val tempName = newName + Downloader.TMP_DIR_SUFFIX if (!oldFolder.renameTo(tempName)) { logcat(LogPriority.ERROR) { "Failed to rename source download folder: ${oldFolder.name}" } - return + return@withContext } } @@ -369,12 +354,12 @@ class DownloadManager( * @param manga the manga * @param newTitle the new manga title. */ - suspend fun renameManga(manga: Manga, newTitle: String) { + suspend fun renameManga(manga: Manga, newTitle: String) = withContext(Dispatchers.IO) { val source = sourceManager.getOrStub(manga.source) - val oldFolder = provider.findMangaDir(manga.title, source) ?: return + val oldFolder = provider.findMangaDir(manga.title, source) ?: return@withContext val newName = provider.getMangaDirName(newTitle) - if (oldFolder.name == newName) return + if (oldFolder.name == newName) return@withContext // just to be safe, don't allow downloads for this manga while renaming it downloader.removeFromQueue(manga) @@ -384,7 +369,7 @@ class DownloadManager( val tempName = newName + Downloader.TMP_DIR_SUFFIX if (!oldFolder.renameTo(tempName)) { logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" } - return + return@withContext } } @@ -403,33 +388,36 @@ class DownloadManager( * @param oldChapter the existing chapter with the old name. * @param newChapter the target chapter with the new name. */ - suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) { - val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url) - val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e -> - logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" } - return + suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) = + withContext(Dispatchers.IO) { + val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url) + val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e -> + logcat(LogPriority.ERROR, e) { + "Manga download folder doesn't exist. Skipping renaming after source sync" + } + return@withContext + } + + // Assume there's only 1 version of the chapter name formats present + val oldDownload = oldNames.asSequence() + .mapNotNull { mangaDir.findFile(it) } + .firstOrNull() ?: return@withContext + + var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) + if (oldDownload.isFile && oldDownload.extension == "cbz") { + newName += ".cbz" + } + + if (oldDownload.name == newName) return@withContext + + if (oldDownload.renameTo(newName)) { + cache.removeChapter(oldChapter, manga) + cache.addChapter(newName, mangaDir, manga) + } else { + logcat(LogPriority.ERROR) { "Could not rename downloaded chapter: ${oldNames.joinToString()}" } + } } - // Assume there's only 1 version of the chapter name formats present - val oldDownload = oldNames.asSequence() - .mapNotNull { mangaDir.findFile(it) } - .firstOrNull() ?: return - - var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) - if (oldDownload.isFile && oldDownload.extension == "cbz") { - newName += ".cbz" - } - - if (oldDownload.name == newName) return - - if (oldDownload.renameTo(newName)) { - cache.removeChapter(oldChapter, manga) - cache.addChapter(newName, mangaDir, manga) - } else { - logcat(LogPriority.ERROR) { "Could not rename downloaded chapter: ${oldNames.joinToString()}" } - } - } - private suspend fun getChaptersToDelete(chapters: List, manga: Manga): List { // Retrieve the categories that are set to exclude from being deleted on read val categoriesToExclude = downloadPreferences.removeExcludeCategories.get().map(String::toLong) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt index fd53d0c0c..ca0955ce2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadPendingDeleter.kt @@ -25,7 +25,7 @@ class DownloadPendingDeleter( /** * Preferences used to store the list of chapters to delete. */ - private val preferences = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE) + private val preferences by lazy { context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE) } /** * Last added chapter, used to avoid decoding from the preference too often. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index bbe1a3095..8f6b8aef7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -8,6 +8,8 @@ import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.util.lang.Hash.md5 import eu.kanade.tachiyomi.util.storage.DiskUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.displayablePath @@ -42,7 +44,10 @@ class DownloadProvider( * @param mangaTitle the title of the manga to query. * @param source the source of the manga. */ - internal fun getMangaDir(mangaTitle: String, source: Source): Result { + internal suspend fun getMangaDir(mangaTitle: String, source: Source): Result = + withContext(Dispatchers.IO) { createMangaDir(mangaTitle, source) } + + private fun createMangaDir(mangaTitle: String, source: Source): Result { val downloadsDir = downloadsDir if (downloadsDir == null) { logcat(LogPriority.ERROR) { "Failed to create download directory" } @@ -79,8 +84,8 @@ class DownloadProvider( * * @param source the source to query. */ - fun findSourceDir(source: Source): UniFile? { - return downloadsDir?.findFile(getSourceDirName(source)) + suspend fun findSourceDir(source: Source): UniFile? = withContext(Dispatchers.IO) { + downloadsDir?.findFile(getSourceDirName(source)) } /** @@ -89,9 +94,8 @@ class DownloadProvider( * @param mangaTitle the title of the manga to query. * @param source the source of the manga. */ - fun findMangaDir(mangaTitle: String, source: Source): UniFile? { - val sourceDir = findSourceDir(source) - return sourceDir?.findFile(getMangaDirName(mangaTitle)) + suspend fun findMangaDir(mangaTitle: String, source: Source): UniFile? = withContext(Dispatchers.IO) { + findSourceDir(source)?.findFile(getMangaDirName(mangaTitle)) } /** @@ -102,15 +106,15 @@ class DownloadProvider( * @param mangaTitle the title of the manga to query. * @param source the source of the chapter. */ - fun findChapterDir( + suspend fun findChapterDir( chapterName: String, chapterScanlator: String?, chapterUrl: String, mangaTitle: String, source: Source, - ): UniFile? { + ): UniFile? = withContext(Dispatchers.IO) { val mangaDir = findMangaDir(mangaTitle, source) - return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence() + getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence() .mapNotNull { mangaDir?.findFile(it) } .firstOrNull() } @@ -122,9 +126,13 @@ class DownloadProvider( * @param manga the manga of the chapter. * @param source the source of the chapter. */ - fun findChapterDirs(chapters: List, manga: Manga, source: Source): Pair> { - val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() - return mangaDir to chapters.mapNotNull { chapter -> + suspend fun findChapterDirs( + chapters: List, + manga: Manga, + source: Source, + ): Pair> = withContext(Dispatchers.IO) { + val mangaDir = findMangaDir(manga.title, source) ?: return@withContext null to emptyList() + mangaDir to chapters.mapNotNull { chapter -> getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence() .mapNotNull { mangaDir.findFile(it) } .firstOrNull() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt index 3554c318c..c0e3b75bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt @@ -30,7 +30,7 @@ class DownloadStore( /** * Preference file where active downloads are stored. */ - private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE) + private val preferences by lazy { context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE) } /** * Counter used to keep the queue order. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadWorker.kt index 430db5ae3..d7c65eea0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadWorker.kt @@ -16,9 +16,11 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.activeNetworkState import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.setForegroundSafely +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import mihon.app.di.AppGraph import mihon.core.metro.metroGraph import tachiyomi.domain.download.service.DownloadPreferences @@ -74,7 +76,10 @@ class DownloadWorker(context: Context, workerParams: WorkerParameters) : Corouti networkIssue = networkIssue() } } finally { - if (downloader.isRunning && (networkIssue != null || isStopped)) downloader.stop(networkIssue) + // Stopped by WorkManager means already canceled, and taking the lock would then throw instead of stopping + if (downloader.isRunning && (networkIssue != null || isStopped)) { + withContext(NonCancellable) { downloader.stop(networkIssue) } + } } return Result.success() 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 a01a0f966..6b928e9f4 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 @@ -21,6 +21,7 @@ import eu.kanade.tachiyomi.util.storage.saveTo import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -39,9 +40,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.core.archive.ZipWriter @@ -49,7 +53,6 @@ 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.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE @@ -93,7 +96,12 @@ class Downloader( private val _queueState = MutableStateFlow>(emptyList()) val queueState = _queueState.asStateFlow() + // Held by every start, stop and queue edit, which come from the main thread, the download worker and the + // deletion jobs, so a check of isRunning can't interleave with another caller's start or stop + private val lock = Mutex() + // Kept when canceled, so the next job can wait for it to finish + @Volatile private var downloaderJob: Job? = null /** @@ -109,8 +117,8 @@ class Downloader( var isPaused: Boolean = false init { - scope.launch { - addAllToQueue(store.restore()) + scope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { + addAllToQueue(withContext(Dispatchers.IO) { store.restore() }) } } @@ -120,7 +128,42 @@ class Downloader( * * @return true if the downloader is started, false otherwise. */ - fun start(): Boolean { + suspend fun start(): Boolean = lock.withLock { startLocked() } + + /** + * Stops the downloader. + */ + suspend fun stop(reason: String? = null) = lock.withLock { stopLocked(reason) } + + // Download jobs check these under the lock: by the time they get it, chapters may have been queued or the + // downloader stopped and started again, and that newer run must not be stopped + private suspend fun stopIfFinished(owner: Job) = lock.withLock { + if (downloaderJob === owner && owner.isActive && areAllDownloadsFinished()) stopLocked() + } + + private suspend fun stopIfCurrent(owner: Job) = lock.withLock { + if (downloaderJob === owner && owner.isActive) stopLocked() + } + + /** + * Pauses and stops the downloader, keeping the queue for a later start + */ + suspend fun pause() = lock.withLock { + pauseLocked() + stopLocked() + } + + /** + * Removes everything from the queue. + */ + suspend fun clearQueue() = lock.withLock { + cancelDownloaderJob() + + internalClearQueue() + notifier.dismissProgress() + } + + private fun startLocked(): Boolean { if (isRunning || queueState.value.isEmpty()) { return false } @@ -135,10 +178,7 @@ class Downloader( return pending.isNotEmpty() } - /** - * Stops the downloader. - */ - fun stop(reason: String? = null) { + private fun stopLocked(reason: String? = null) { cancelDownloaderJob() queueState.value .filter { it.status == Download.State.DOWNLOADING } @@ -158,10 +198,7 @@ class Downloader( isPaused = false } - /** - * Pauses the downloader - */ - fun pause() { + private fun pauseLocked() { cancelDownloaderJob() queueState.value .filter { it.status == Download.State.DOWNLOADING } @@ -169,16 +206,6 @@ class Downloader( isPaused = true } - /** - * Removes everything from the queue. - */ - fun clearQueue() { - cancelDownloaderJob() - - internalClearQueue() - notifier.dismissProgress() - } - /** * Prepares the subscriptions to start downloading. */ @@ -190,6 +217,7 @@ class Downloader( // Page writes block, so a canceled job can still be writing the pages this one would start on. Not // cancelable, so a job canceled while waiting still finishes after the one before it. withContext(NonCancellable) { previousJob?.join() } + val owner = coroutineContext.job val activeDownloadsFlow = combine( queueState, @@ -238,7 +266,7 @@ class Downloader( val sourcesToStart = activeSources.filter { it !in sourceJobs } sourcesToStart.forEach { source -> - sourceJobs[source] = launchSourceJob(source, downloadJobs) + sourceJobs[source] = launchSourceJob(source, downloadJobs, owner) } } } @@ -253,7 +281,8 @@ class Downloader( private fun CoroutineScope.launchSourceJob( source: HttpSource, downloadJobs: MutableMap, - ) = launchIO { + owner: Job, + ) = launch(Dispatchers.IO) { val slots = Semaphore(downloadPreferences.parallelPageLimit.get()) while (true) { // A canceled job can mark a chapter as downloading after it's been reset, so that counts as waiting too @@ -268,9 +297,13 @@ class Downloader( .filterNotNull() .first() val pagesStarted = CompletableDeferred() - val job = launchDownloadJob(download, slots, pagesStarted) + val job = launchDownloadJob(download, slots, pagesStarted, owner) downloadJobs[download] = job - job.invokeOnCompletion { downloadJobs.remove(download, job) } + job.invokeOnCompletion { + downloadJobs.remove(download, job) + // A job canceled before it starts never reaches its finally block + pagesStarted.complete(Unit) + } pagesStarted.await() } } @@ -279,22 +312,24 @@ class Downloader( download: Download, slots: Semaphore, pagesStarted: CompletableDeferred, - ) = launchIO { + owner: Job, + ) = launch(Dispatchers.IO) { try { + // Removed from the queue between being picked and starting + if (download !in queueState.value) return@launch + downloadChapter(download, slots, pagesStarted) // Remove successful download from queue if (download.status == Download.State.DOWNLOADED) { removeFromQueue(download) } - if (areAllDownloadsFinished()) { - stop() - } + stopIfFinished(owner) } catch (e: Throwable) { if (e is CancellationException) throw e logcat(LogPriority.ERROR, e) notifier.onError(e.message) - stop() + stopIfCurrent(owner) } finally { // Also when the chapter fails before its pages, so the source moves on to the next one pagesStarted.complete(Unit) @@ -321,16 +356,19 @@ class Downloader( val source = sourceManager.get(manga.source) as? HttpSource ?: return false val wasEmpty = queueState.value.isEmpty() - val chaptersToQueue = chapters.asSequence() - // Filter out those already downloaded. - .filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null } - // Add chapters to queue from the start. - .sortedByDescending { it.sourceOrder } - // Filter out those already enqueued. - .filter { chapter -> queueState.value.none { it.chapter.id == chapter.id } } - // Create a download for each one. - .map { Download(source, manga, it) } - .toList() + val chaptersToQueue = withContext(Dispatchers.IO) { + chapters + // Filter out those already downloaded. + .filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null } + .asSequence() + // Add chapters to queue from the start. + .sortedByDescending { it.sourceOrder } + // Filter out those already enqueued. + .filter { chapter -> queueState.value.none { it.chapter.id == chapter.id } } + // Create a download for each one. + .map { Download(source, manga, it) } + .toList() + } if (chaptersToQueue.isNotEmpty()) { addAllToQueue(chaptersToQueue) @@ -725,12 +763,14 @@ class Downloader( } private fun addAllToQueue(downloads: List) { - _queueState.update { - downloads.forEach { download -> + _queueState.update { queue -> + // Callers filter off the queue's thread, so a chapter may have been queued in the meantime + val toAdd = downloads.filter { download -> queue.none { it.chapter.id == download.chapter.id } } + toAdd.forEach { download -> download.status = Download.State.QUEUE } - store.addAll(downloads) - it + downloads + store.addAll(toAdd) + queue + toAdd } } @@ -757,12 +797,12 @@ class Downloader( } } - fun removeFromQueue(chapters: List) { + private fun removeFromQueue(chapters: List) { val chapterIds = chapters.map { it.id } removeFromQueueIf { it.chapter.id in chapterIds } } - fun removeFromQueue(manga: Manga) { + suspend fun removeFromQueue(manga: Manga) = lock.withLock { removeFromQueueIf { it.manga.id == manga.id } } @@ -778,21 +818,44 @@ class Downloader( } } - fun updateQueue(downloads: List) { - val wasRunning = isRunning - - if (downloads.isEmpty()) { - clearQueue() - stop() - return + /** + * Replaces the queue with [transform] applied to the current queue. It runs under the lock, so downloads + * queued or removed meanwhile aren't lost to a stale copy of the queue. + */ + suspend fun updateQueue(transform: (List) -> List) = editQueue { + // One update, since chapters are queued without the lock + _queueState.update { queue -> + val downloads = transform(queue).distinctBy { it.chapter.id } + val kept = downloads.mapTo(HashSet()) { it.chapter.id } + queue.forEach { download -> + if (download.chapter.id !in kept && + (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) + ) { + download.status = Download.State.NOT_DOWNLOADED + } + } + downloads.forEach { it.status = Download.State.QUEUE } + store.clear() + store.addAll(downloads) + downloads } + } - pause() - internalClearQueue() - addAllToQueue(downloads) + /** + * Removes [chapters] from the queue, keeping the state of the downloads that stay. + */ + suspend fun dequeue(chapters: List) = editQueue { + removeFromQueue(chapters) + } + + private suspend inline fun editQueue(edit: () -> Unit) = lock.withLock { + val wasRunning = isRunning + if (wasRunning) pauseLocked() + + edit() if (wasRunning) { - start() + if (queueState.value.isEmpty()) stopLocked() else startLocked() } } 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 0e8940311..0ce591e51 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,12 +29,9 @@ 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 -import tachiyomi.core.common.util.lang.launchUI import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.Manga @@ -46,7 +43,6 @@ 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, @@ -173,7 +169,7 @@ class LibraryUpdateNotifier( * * @param updates a list of manga with new updates. */ - fun showUpdateNotifications(updates: List>>) { + suspend fun showUpdateNotifications(updates: List>>) { // Parent group notification context.notify( Notifications.ID_NEW_CHAPTERS, @@ -216,16 +212,14 @@ class LibraryUpdateNotifier( // Per-manga notification if (!securityPreferences.hideNotificationContent.get()) { - scope.launchUI { - context.notify( - updates.map { (manga, chapters) -> - NotificationManagerCompat.NotificationWithIdAndTag( - manga.id.hashCode(), - createNewChaptersNotification(manga, chapters), - ) - }, - ) - } + context.notify( + updates.map { (manga, chapters) -> + NotificationManagerCompat.NotificationWithIdAndTag( + manga.id.hashCode(), + createNewChaptersNotification(manga, chapters), + ) + }, + ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt index 2eb044dbd..8e2d9f4d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateWorker.kt @@ -31,12 +31,15 @@ import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.workManager import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import logcat.LogPriority @@ -47,7 +50,6 @@ import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.source.interactor.UpdateMangaFromRemote import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.preference.getAndSet -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.Category import tachiyomi.domain.chapter.model.Chapter @@ -128,7 +130,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara val categoryId = inputData.getLong(KEY_CATEGORY, -1L) addMangaToQueue(categoryId) - return withIOContext { + return withContext(Dispatchers.IO) { try { updateChapterList() Result.success() @@ -290,7 +292,10 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara hasDownloads.store(true) } - libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size } + // Sources update concurrently, and getAndSet is a separate read and write + synchronized(libraryPreferences.newUpdatesCount) { + libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size } + } // Convert to the manga that contains new chapters newUpdates.add(manga to newChapters.toTypedArray()) @@ -485,7 +490,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara } } - fun startNow( + suspend fun startNow( workManager: WorkManager, category: Category? = null, ): Boolean { @@ -507,12 +512,12 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara return true } - fun stop(context: Context) { + suspend fun stop(context: Context) { val workManager = context.workManager val workQuery = WorkQuery.Builder.fromTags(listOf(TAG)) .addStates(listOf(WorkInfo.State.RUNNING)) .build() - workManager.getWorkInfos(workQuery).get() + workManager.getWorkInfosFlow(workQuery).first() // Should only return one work but just in case .forEach { workManager.cancelWorkById(it.id) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateWorker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateWorker.kt index 607d28013..9df1017cc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateWorker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateWorker.kt @@ -14,17 +14,18 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.AppGraph import mihon.core.metro.metroGraph import mihon.domain.source.interactor.UpdateMangaFromRemote -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga @@ -58,7 +59,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) : addMangaToQueue() - return withIOContext { + return withContext(Dispatchers.IO) { try { updateMetadata() Result.success() @@ -172,7 +173,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) : private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 - fun startNow(workManager: WorkManager): Boolean { + suspend fun startNow(workManager: WorkManager): Boolean { if (workManager.isRunning(TAG)) { // Already running either as a scheduled or manual job return false 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 552b2d47f..85ab02582 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 @@ -16,11 +16,9 @@ import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat 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 @@ -32,7 +30,6 @@ import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager -import tachiyomi.i18n.MR import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID /** @@ -66,9 +63,9 @@ class NotificationReceiver : BroadcastReceiver() { // Resume the download service ACTION_RESUME_DOWNLOADS -> downloadManager.startDownloads() // Pause the download service - ACTION_PAUSE_DOWNLOADS -> downloadManager.pauseDownloads() + ACTION_PAUSE_DOWNLOADS -> launchAsync { downloadManager.pauseDownloads() } // Clear the download queue - ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue() + ACTION_CLEAR_DOWNLOADS -> launchAsync { downloadManager.clearQueue() } // Launch share activity and dismiss notification ACTION_SHARE_IMAGE -> shareImage( @@ -85,14 +82,6 @@ class NotificationReceiver : BroadcastReceiver() { ACTION_CANCEL_RESTORE -> cancelRestore(context) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) - // Open reader activity - ACTION_OPEN_CHAPTER -> { - openChapter( - context, - intent.getLongExtra(EXTRA_MANGA_ID, -1), - intent.getLongExtra(EXTRA_CHAPTER_ID, -1), - ) - } // Mark updated manga chapters as read ACTION_MARK_AS_READ -> { val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) @@ -149,26 +138,6 @@ class NotificationReceiver : BroadcastReceiver() { context.startActivity(uri.toShareIntent(context, fileMimeType)) } - /** - * Starts reader activity - * - * @param context context of application - * @param mangaId id of manga - * @param chapterId id of chapter - */ - private fun openChapter(context: Context, mangaId: Long, chapterId: Long) { - val manga = runBlocking { getManga.await(mangaId) } - val chapter = runBlocking { getChapter.await(chapterId) } - if (manga != null && chapter != null) { - val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - context.startActivity(intent) - } else { - context.toast(MR.strings.chapter_error) - } - } - /** * Method called when user wants to stop a backup restore job. * @@ -184,7 +153,9 @@ class NotificationReceiver : BroadcastReceiver() { * @param context context of application */ private fun cancelLibraryUpdate(context: Context) { - LibraryUpdateWorker.stop(context) + launchAsync { + LibraryUpdateWorker.stop(context) + } } /** @@ -253,7 +224,6 @@ class NotificationReceiver : BroadcastReceiver() { private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" - private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER" private const val ACTION_OPEN_ENTRY = "$ID.$NAME.ACTION_OPEN_ENTRY" @@ -268,7 +238,6 @@ class NotificationReceiver : BroadcastReceiver() { private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID" private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID" - private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID" private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt index a98aa3fad..8bcc0efdc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/BaseTracker.kt @@ -9,17 +9,17 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import okhttp3.OkHttpClient -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.track.interactor.UpsertTrack import uy.kohesive.injekt.Injekt @@ -115,7 +115,7 @@ abstract class BaseTracker( try { addTracks.bind(this, item, mangaId) } catch (e: Throwable) { - withUIContext { + withContext(Dispatchers.Main) { context.toast(e.message) } } @@ -165,7 +165,7 @@ abstract class BaseTracker( updateRemote(track) } - private suspend fun updateRemote(track: Track): Unit = withIOContext { + private suspend fun updateRemote(track: Track): Unit = withContext(Dispatchers.IO) { try { update(track) track.toDomainTrack(idRequired = false)?.let { @@ -173,7 +173,7 @@ abstract class BaseTracker( } } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" } - withUIContext { + withContext(Dispatchers.Main) { context.toast(e.message) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index aceb9d733..06546c5b6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -14,6 +14,8 @@ import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject @@ -26,7 +28,6 @@ import okhttp3.Headers.Companion.headersOf import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy class BangumiApi( @@ -40,7 +41,7 @@ class BangumiApi( private val authClient = client.newBuilder().addInterceptor(interceptor).build() suspend fun addLibManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val body = buildJsonObject { put("type", track.toApiStatus()) @@ -52,13 +53,13 @@ class BangumiApi( .toRequestBody() // Returns with 202 Accepted on success with no body authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) - .awaitSuccess() + .awaitSuccess().close() track } } suspend fun updateLibManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val body = buildJsonObject { put("type", track.toApiStatus()) @@ -76,7 +77,7 @@ class BangumiApi( .build() // Returns with 204 No Content authClient.newCall(request) - .awaitSuccess() + .awaitSuccess().close() track } @@ -87,7 +88,7 @@ class BangumiApi( // but that has been the case since 2022 with few significant // changes to the schema for this endpoint since // "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动" - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_URL/v0/search/subjects?limit=20" val body = buildJsonObject { put("keyword", search) @@ -112,7 +113,7 @@ class BangumiApi( } suspend fun getMangaDetails(id: Int): TrackSearch? { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_URL/v0/subjects/$id" with(json) { @@ -126,7 +127,7 @@ class BangumiApi( } suspend fun statusLibManga(track: Track, username: String): Track? { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_URL/v0/users/$username/collections/${track.remote_id}" with(json) { try { @@ -152,7 +153,7 @@ class BangumiApi( } suspend fun accessToken(code: String): BGMOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { val body = FormBody.Builder() .add("grant_type", "authorization_code") .add("client_id", CLIENT_ID) @@ -169,7 +170,7 @@ class BangumiApi( } suspend fun getCurrentUser(): BGMUser { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { authClient.newCall(GET("$API_URL/v0/me")) .awaitSuccess() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index aab48e854..892d967d4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -15,6 +15,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { /** * OAuth object used for authenticated requests. */ + @Volatile private var oauth: BGMOAuth? = bangumi.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { @@ -23,15 +24,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { var currAuth: BGMOAuth = oauth ?: throw Exception("Not authenticated with Bangumi") if (currAuth.isExpired()) { - val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!)) - if (response.isSuccessful) { - currAuth = with(json) { - response.parseAs() - } - newAuth(currAuth) - } else { - response.close() - } + currAuth = refreshToken(chain, currAuth) } return originalRequest.newBuilder() @@ -44,6 +37,18 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { .let(chain::proceed) } + private fun refreshToken(chain: Interceptor.Chain, expired: BGMOAuth): BGMOAuth = synchronized(this) { + val current = oauth ?: expired + if (!current.isExpired()) return@synchronized current + + val response = chain.proceed(BangumiApi.refreshTokenRequest(current.refreshToken!!)) + if (!response.isSuccessful) { + response.close() + return@synchronized current + } + with(json) { response.parseAs() }.also(::newAuth) + } + fun newAuth(oauth: BGMOAuth?) { this.oauth = oauth bangumi.saveToken(oauth) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt index 662924ee1..33d3c5884 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt @@ -18,6 +18,8 @@ import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray @@ -27,7 +29,6 @@ import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack @@ -37,7 +38,7 @@ class HikkaApi( interceptor: HikkaInterceptor, ) { suspend fun getCurrentUser(): HKUser { - return withIOContext { + return withContext(Dispatchers.IO) { val request = Request.Builder() .url("${BASE_API_URL}/user/me") .get() @@ -51,7 +52,7 @@ class HikkaApi( } suspend fun accessToken(reference: String): HKOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { client.newCall(authTokenCreate(reference)) .awaitSuccess() @@ -61,7 +62,7 @@ class HikkaApi( } suspend fun searchManga(query: String): List { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$BASE_API_URL/manga".toUri().buildUpon() .appendQueryParameter("page", "1") .appendQueryParameter("size", "50") @@ -101,7 +102,7 @@ class HikkaApi( } suspend fun getMangaDetails(slug: String): TrackSearch? { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$BASE_API_URL/manga/$slug" with(json) { @@ -120,7 +121,7 @@ class HikkaApi( } suspend fun getRead(track: Track): HKRead? { - return withIOContext { + return withContext(Dispatchers.IO) { val slug = track.tracking_url.split("/")[4] val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build() with(json) { @@ -140,7 +141,7 @@ class HikkaApi( } suspend fun getManga(track: Track): TrackSearch { - return withIOContext { + return withContext(Dispatchers.IO) { val slug = track.tracking_url.split("/")[4] val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon() .build() @@ -155,19 +156,19 @@ class HikkaApi( } suspend fun deleteUserManga(track: DomainTrack) { - return withIOContext { + return withContext(Dispatchers.IO) { val slug = track.remoteUrl.split("/")[4] val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon() .build() authClient.newCall(DELETE(url.toString())) - .awaitSuccess() + .awaitSuccess().close() } } suspend fun addUserManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val slug = track.tracking_url.split("/")[4] val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt index 338bd2637..2eee1ed56 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaInterceptor.kt @@ -10,33 +10,17 @@ import uy.kohesive.injekt.injectLazy class HikkaInterceptor(private val hikka: Hikka) : Interceptor { private val json: Json by injectLazy() + + @Volatile private var oauth: HKOAuth? = hikka.loadOAuth() override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - val currAuth = oauth ?: throw Exception("Hikka: You are not authorized") + var currAuth = oauth ?: throw Exception("Hikka: You are not authorized") if (currAuth.isExpired()) { - val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken)) - if (!refreshTokenResponse.isSuccessful) { - refreshTokenResponse.close() - hikka.logout() - throw Exception("Hikka: The token is expired") - } else { - refreshTokenResponse.close() - } - - val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken)) - if (!authTokenInfoResponse.isSuccessful) { - authTokenInfoResponse.close() - throw Exception("Hikka: Auth token info failed") - } - - val authTokenInfo = with(json) { - authTokenInfoResponse.parseAs() - } - setAuth(HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created)) + currAuth = refreshToken(chain, currAuth) } val authRequest = originalRequest.newBuilder() @@ -47,6 +31,30 @@ class HikkaInterceptor(private val hikka: Hikka) : Interceptor { return chain.proceed(authRequest) } + private fun refreshToken(chain: Interceptor.Chain, expired: HKOAuth): HKOAuth = synchronized(this) { + val currAuth = oauth ?: expired + if (!currAuth.isExpired()) return@synchronized currAuth + + val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken)) + if (!refreshTokenResponse.isSuccessful) { + refreshTokenResponse.close() + hikka.logout() + throw Exception("Hikka: The token is expired") + } + refreshTokenResponse.close() + + val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken)) + if (!authTokenInfoResponse.isSuccessful) { + authTokenInfoResponse.close() + throw Exception("Hikka: Auth token info failed") + } + + val authTokenInfo = with(json) { + authTokenInfoResponse.parseAs() + } + HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created).also(::setAuth) + } + fun setAuth(oauth: HKOAuth?) { this.oauth = oauth hikka.saveOAuth(oauth) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt index 583bf2eec..1cc7ed87c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt @@ -7,12 +7,13 @@ import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import logcat.LogPriority import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.injectLazy import java.io.IOException @@ -145,7 +146,7 @@ class KavitaApi( return 0.0 } - suspend fun getTrackSearch(url: String): TrackSearch = withIOContext { + suspend fun getTrackSearch(url: String): TrackSearch = withContext(Dispatchers.IO) { try { val seriesDto: SeriesDto = with(json) { authClient.newCall(GET(url)) @@ -182,7 +183,7 @@ class KavitaApi( authClient.newCall( POST(requestUrl, body = EMPTY_JSON_BODY), ) - .awaitSuccess() + .awaitSuccess().close() return getTrackSearch(track.tracking_url) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 380f3c822..0e3ca3b04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.dataOrElse import eu.kanade.tachiyomi.network.parseAs import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import mihon.graphql.kitsu.KitsuAddLibMangaMutation import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation @@ -25,7 +26,6 @@ import mihon.graphql.kitsu.KitsuUpdateLibMangaMutation import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.RequestBody -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.injectLazy import kotlin.time.Instant @@ -181,7 +181,7 @@ class KitsuApi( } suspend fun login(username: String, password: String): KitsuOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { val formBody: RequestBody = FormBody.Builder() .add("username", username) .add("password", password) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt index be5a8cef4..88f7acf0c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt @@ -15,6 +15,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor { /** * OAuth object used for authenticated requests. */ + @Volatile private var oauth: KitsuOAuth? = kitsu.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { @@ -22,18 +23,19 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor { var currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") - val refreshToken = currAuth.refreshToken!! - - // Refresh access token if expired. + // Refresh access token if expired, once for all the requests that found it expired. if (currAuth.isExpired()) { - val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken)) - if (response.isSuccessful) { - currAuth = with(json) { - response.parseAs() + currAuth = synchronized(this) { + val latest = oauth ?: throw Exception("Not authenticated with Kitsu") + if (!latest.isExpired()) return@synchronized latest + + val response = chain.proceed(KitsuApi.refreshTokenRequest(latest.refreshToken!!)) + if (response.isSuccessful) { + with(json) { response.parseAs() }.also(::newAuth) + } else { + response.close() + latest } - newAuth(currAuth) - } else { - response.close() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt index 4395db8e1..8d8fbc271 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt @@ -6,6 +6,8 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import logcat.LogPriority import okhttp3.Headers @@ -13,7 +15,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.injectLazy @@ -33,7 +34,7 @@ class KomgaApi( private val json: Json by injectLazy() suspend fun getTrackSearch(url: String): TrackSearch = - withIOContext { + withContext(Dispatchers.IO) { try { val track = with(json) { if (url.contains(READLIST_API)) { @@ -93,7 +94,7 @@ class KomgaApi( .put(payload.toRequestBody("application/json".toMediaType())) .build(), ) - .awaitSuccess() + .awaitSuccess().close() return getTrackSearch(track.tracking_url) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt index db1775d0e..baabcc23e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt @@ -21,6 +21,8 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.lang.toLocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -31,7 +33,6 @@ import okhttp3.FormBody import okhttp3.Headers.Companion.headersOf import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import java.math.RoundingMode import java.security.SecureRandom @@ -64,7 +65,7 @@ class MangaBakaApi( private val authClient = client.newBuilder().addInterceptor(interceptor).build() suspend fun addLibManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$LIBRARY_API_URL/${track.remote_id}" val body = buildJsonObject { put("is_private", track.private) @@ -87,7 +88,7 @@ class MangaBakaApi( authClient .newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) - .awaitSuccess() + .awaitSuccess().close() // only returns 201 with the body { "status": 201, "data": true }, so no library ID for us track @@ -95,17 +96,17 @@ class MangaBakaApi( } suspend fun deleteLibManga(track: DomainTrack) { - withIOContext { + withContext(Dispatchers.IO) { val url = "$LIBRARY_API_URL/${track.remoteId}" authClient .newCall(DELETE(url)) - .awaitSuccess() + .awaitSuccess().close() } } suspend fun findLibManga(track: Track): Track? { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { try { val url = "$LIBRARY_API_URL/${track.remote_id}" @@ -141,7 +142,7 @@ class MangaBakaApi( } suspend fun updateLibManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$LIBRARY_API_URL/${track.remote_id}" val body = buildJsonObject { put("state", track.toApiStatus()) @@ -172,14 +173,14 @@ class MangaBakaApi( authClient .newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON))) - .awaitSuccess() + .awaitSuccess().close() track } } suspend fun search(search: String): List { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon() .appendQueryParameter("q", search) .appendQueryParameter("type_not", "novel") @@ -213,7 +214,7 @@ class MangaBakaApi( } suspend fun getMangaDetails(id: Int): TrackSearch? { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$API_BASE_URL/v1/series".toUri().buildUpon() .appendPath(id.toString()) .build() @@ -235,7 +236,7 @@ class MangaBakaApi( } suspend fun getCurrentUser(): MangaBakaUserProfile { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { authClient.newCall(GET("$API_BASE_URL/v1/my/profile")) .awaitSuccess() @@ -246,7 +247,7 @@ class MangaBakaApi( } suspend fun getAccessToken(code: String): MangaBakaOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { val formBody = FormBody.Builder() .add("client_id", CLIENT_ID) .add("code", code) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt index 63901f395..81f702acd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt @@ -11,6 +11,7 @@ class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor { private val json: Json by injectLazy() + @Volatile private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { @@ -18,15 +19,19 @@ class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor { var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka") + // Refresh once for all the requests that found the token expired if (currentAuth.isExpired()) { - val response = chain.proceed(MangaBakaApi.refreshTokenRequest(currentAuth.refreshToken)) - if (response.isSuccessful) { - currentAuth = with(json) { - response.parseAs() + currentAuth = synchronized(this) { + val latest = oauth ?: throw Exception("Not authenticated with MangaBaka") + if (!latest.isExpired()) return@synchronized latest + + val response = chain.proceed(MangaBakaApi.refreshTokenRequest(latest.refreshToken)) + if (response.isSuccessful) { + with(json) { response.parseAs() }.also(::setAuth) + } else { + response.close() + latest } - setAuth(currentAuth) - } else { - response.close() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt index 2f7223740..955e5a31c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -17,6 +17,8 @@ import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject @@ -27,7 +29,6 @@ import kotlinx.serialization.json.putJsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack @@ -44,10 +45,12 @@ class MangaUpdatesApi( } suspend fun getSeriesListItem(track: Track): Pair { - val listItem = with(json) { - authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}")) - .awaitSuccess() - .parseAs() + val listItem = withContext(Dispatchers.IO) { + with(json) { + authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}")) + .awaitSuccess() + .parseAs() + } } val rating = getSeriesRating(track) @@ -98,7 +101,7 @@ class MangaUpdatesApi( body = body.toString().toRequestBody(CONTENT_TYPE), ), ) - .awaitSuccess() + .awaitSuccess().close() updateSeriesRating(track) } @@ -113,15 +116,17 @@ class MangaUpdatesApi( body = body.toString().toRequestBody(CONTENT_TYPE), ), ) - .awaitSuccess() + .awaitSuccess().close() } private suspend fun getSeriesRating(track: Track): MURating? { return try { - with(json) { - authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating")) - .awaitSuccess() - .parseAs() + withContext(Dispatchers.IO) { + with(json) { + authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating")) + .awaitSuccess() + .parseAs() + } } } catch (_: Exception) { null @@ -140,12 +145,12 @@ class MangaUpdatesApi( body = body.toString().toRequestBody(CONTENT_TYPE), ), ) - .awaitSuccess() + .awaitSuccess().close() } else { authClient.newCall( DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"), ) - .awaitSuccess() + .awaitSuccess().close() } } @@ -161,22 +166,24 @@ class MangaUpdatesApi( ) } - return with(json) { - client.newCall( - POST( - url = "$BASE_URL/v1/series/search", - body = body.toString().toRequestBody(CONTENT_TYPE), - ), - ) - .awaitSuccess() - .parseAs() - .results - .map { it.record } + return withContext(Dispatchers.IO) { + with(json) { + client.newCall( + POST( + url = "$BASE_URL/v1/series/search", + body = body.toString().toRequestBody(CONTENT_TYPE), + ), + ) + .awaitSuccess() + .parseAs() + .results + .map { it.record } + } } } suspend fun getSeriesDetails(id: Long): MURecord? { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$BASE_URL/v1/series/$id" with(json) { @@ -197,24 +204,28 @@ class MangaUpdatesApi( put("username", username) put("password", password) } - return with(json) { - client.newCall( - PUT( - url = "$BASE_URL/v1/account/login", - body = body.toString().toRequestBody(CONTENT_TYPE), - ), - ) - .awaitSuccess() - .parseAs() - .context + return withContext(Dispatchers.IO) { + with(json) { + client.newCall( + PUT( + url = "$BASE_URL/v1/account/login", + body = body.toString().toRequestBody(CONTENT_TYPE), + ), + ) + .awaitSuccess() + .parseAs() + .context + } } } suspend fun getCurrentUser(): MUCurrentUser { - return with(json) { - authClient.newCall(GET("$BASE_URL/v1/account/profile")) - .awaitSuccess() - .parseAs() + return withContext(Dispatchers.IO) { + with(json) { + authClient.newCall(GET("$BASE_URL/v1/account/profile")) + .awaitSuccess() + .parseAs() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index d0c21ff88..159a142cd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -18,13 +18,14 @@ import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.PkceUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.FormBody import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat import java.util.Locale @@ -41,7 +42,7 @@ class MyAnimeListApi( private val authClient = client.newBuilder().addInterceptor(interceptor).build() suspend fun getAccessToken(authCode: String): MALOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { val formBody: RequestBody = FormBody.Builder() .add("client_id", CLIENT_ID) .add("code", authCode) @@ -57,7 +58,7 @@ class MyAnimeListApi( } suspend fun getCurrentUser(): String { - return withIOContext { + return withContext(Dispatchers.IO) { val request = Request.Builder() .url("$BASE_API_URL/users/@me") .get() @@ -72,7 +73,7 @@ class MyAnimeListApi( } suspend fun search(query: String): List { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$BASE_API_URL/manga".toUri().buildUpon() // MAL API throws a 400 when the query is over 64 characters... .appendQueryParameter("q", query.take(64)) @@ -91,7 +92,7 @@ class MyAnimeListApi( } suspend fun getMangaDetails(id: Int): TrackSearch { - return withIOContext { + return withContext(Dispatchers.IO) { val url = "$BASE_API_URL/manga".toUri().buildUpon() .appendPath(id.toString()) .appendQueryParameter("fields", SEARCH_FIELDS) @@ -106,7 +107,7 @@ class MyAnimeListApi( } suspend fun updateItem(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val formBodyBuilder = FormBody.Builder() .add("status", track.toMyAnimeListStatus() ?: "reading") .add("is_rereading", (track.status == MyAnimeList.REREADING).toString()) @@ -148,15 +149,15 @@ class MyAnimeListApi( } suspend fun deleteItem(track: DomainTrack) { - withIOContext { + withContext(Dispatchers.IO) { authClient .newCall(DELETE(mangaUrl(track.remoteId).toString())) - .awaitSuccess() + .awaitSuccess().close() } } suspend fun findListItem(track: Track): Track? { - return withIOContext { + return withContext(Dispatchers.IO) { val uri = "$BASE_API_URL/manga".toUri().buildUpon() .appendPath(track.remote_id.toString()) .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") @@ -174,7 +175,7 @@ class MyAnimeListApi( } suspend fun findListItems(query: String, offset: Int = 0): List { - return withIOContext { + return withContext(Dispatchers.IO) { val myListSearchResult = getListPage(offset) val matches = myListSearchResult.data @@ -191,7 +192,7 @@ class MyAnimeListApi( } private suspend fun getListPage(offset: Int): MALSearchResult { - return withIOContext { + return withContext(Dispatchers.IO) { val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() .appendQueryParameter("fields", SEARCH_FIELDS) .appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index bc02fa039..62fb99338 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.network.dataOrElse import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -28,7 +29,6 @@ import mihon.graphql.shikimori.ShikimoriSearchMangaQuery import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack @@ -53,7 +53,7 @@ class ShikimoriApi( } suspend fun addLibManga(track: Track, userId: String): Track { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { val payload = buildJsonObject { putJsonObject("user_rate") { @@ -82,7 +82,7 @@ class ShikimoriApi( } suspend fun updateLibManga(track: Track): Track { - return withIOContext { + return withContext(Dispatchers.IO) { val payload = buildJsonObject { putJsonObject("user_rate") { put("chapters", track.last_chapter_read.toInt()) @@ -109,10 +109,10 @@ class ShikimoriApi( } suspend fun deleteLibManga(track: DomainTrack) { - withIOContext { + withContext(Dispatchers.IO) { authClient .newCall(DELETE("$API_URL/v2/user_rates/${track.libraryId}")) - .awaitSuccess() + .awaitSuccess().close() } } @@ -192,7 +192,7 @@ class ShikimoriApi( } suspend fun accessToken(code: String): SMOAuth { - return withIOContext { + return withContext(Dispatchers.IO) { with(json) { client.newCall(accessTokenRequest(code)) .awaitSuccess() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt index 7be14e99f..1457a1adc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt @@ -15,6 +15,7 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor { /** * OAuth object used for authenticated requests. */ + @Volatile private var oauth: SMOAuth? = shikimori.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { @@ -22,21 +23,24 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor { var currAuth = oauth ?: throw Exception("Not authenticated with Shikimori") - // Refresh access token if expired. + // Refresh access token if expired, once for all the requests that found it expired. if (currAuth.isExpired()) { - val response = chain.proceed(ShikimoriApi.refreshTokenRequest(currAuth.refreshToken!!)) - if (response.isSuccessful) { - currAuth = with(json) { - response.parseAs() + currAuth = synchronized(this) { + val latest = oauth ?: throw Exception("Not authenticated with Shikimori") + if (!latest.isExpired()) return@synchronized latest + + val response = chain.proceed(ShikimoriApi.refreshTokenRequest(latest.refreshToken!!)) + if (response.isSuccessful) { + with(json) { response.parseAs() }.also(::newAuth) + } else { + response.close() + latest } - newAuth(currAuth) - } else { - response.close() } } // Add the authorization header to the original request. val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.accessToken}") + .addHeader("Authorization", "Bearer ${currAuth.accessToken}") .header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})") .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt index 64c5c75ee..a1526c1fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt @@ -106,7 +106,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker { private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong() - private fun getPrefTrackerDelete(): Boolean { + private suspend fun getPrefTrackerDelete(): Boolean { val preferences = api.sourcePreferences() return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt index c164c775f..6aeb47557 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt @@ -7,17 +7,14 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.dataOrElse import eu.kanade.tachiyomi.source.ConfigurableSource -import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.sourcePreferences import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import mihon.graphql.suwayomi.SuwayomiGetMangaQuery import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation -import okhttp3.OkHttpClient import tachiyomi.domain.source.service.SourceManager import java.security.MessageDigest @@ -25,28 +22,30 @@ class SuwayomiApi( private val trackerId: Long, private val sourceManager: SourceManager, ) { - // Blocking is fine here: these are only touched from OkHttp and tracker threads. - private val source: Source by lazy { runBlocking { sourceManager.get(sourceId)!! } } - private val httpSource: HttpSource by lazy { source as HttpSource } - private val configurableSource: ConfigurableSource by lazy { source as ConfigurableSource } - private val client: OkHttpClient by lazy { httpSource.client } - private val baseUrl: String by lazy { httpSource.baseUrl.trimEnd('/') } - private val apiUrl: String by lazy { "$baseUrl/api/graphql" } + @Volatile + private var connection: Connection? = null - private val graphQlClient by lazy { - ApolloClient.Builder() - .serverUrl(apiUrl) - .okHttpClient(client) + private suspend fun connection(): Connection { + connection?.let { return it } + val source = sourceManager.get(sourceId) as HttpSource + val baseUrl = source.baseUrl.trimEnd('/') + val graphQlClient = ApolloClient.Builder() + .serverUrl("$baseUrl/api/graphql") + .okHttpClient(source.client) .dispatcher(Dispatchers.IO) // required to log the error body in dataOrElse, which also properly closes it .httpExposeErrorBody(true) .build() + return Connection(source, baseUrl, graphQlClient).also { connection = it } } - fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences() + suspend fun sourcePreferences(): SharedPreferences { + return (connection().source as ConfigurableSource).sourcePreferences() + } suspend fun getTrackSearch(mangaId: Long): TrackSearch? { - return graphQlClient + val connection = connection() + return connection.graphQlClient .query( SuwayomiGetMangaQuery(mangaId = mangaId.toInt()), ) @@ -55,11 +54,12 @@ class SuwayomiApi( errorLog = "Suwayomi: Failed to find manga in library", default = { null }, ) { - it.manga.mangaFragment.toTrackSearch(trackerId, baseUrl) + it.manga.mangaFragment.toTrackSearch(trackerId, connection.baseUrl) } } suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? { + val graphQlClient = connection().graphQlClient val mangaId = track.remote_id val chaptersToMark = graphQlClient @@ -114,4 +114,10 @@ class SuwayomiApi( val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE } + + private class Connection( + val source: HttpSource, + val baseUrl: String, + val graphQlClient: ApolloClient, + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt index 99ace8785..0a7a36bff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt @@ -4,7 +4,8 @@ import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.util.system.isFossBuildType import eu.kanade.tachiyomi.util.system.isNightlyBuildType -import tachiyomi.core.common.util.lang.withIOContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import tachiyomi.domain.release.interactor.GetApplicationRelease @Inject @@ -18,7 +19,7 @@ class AppUpdateChecker( // return GetApplicationRelease.Result.OsTooOld // } - return withIOContext { + return withContext(Dispatchers.IO) { val result = getApplicationRelease.await( GetApplicationRelease.Arguments( isFossBuildType, 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 d8b699e2d..af62cfe09 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -31,17 +31,19 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext 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 -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import java.util.Locale +import java.util.concurrent.ConcurrentHashMap /** * The manager of extensions installed as another apk which extend the available sources. It handles @@ -65,7 +67,10 @@ class ExtensionManager( private val initialized = CompletableDeferred() - private val iconMap = mutableMapOf() + // A full load replaces both maps, so install events wait for it and apply on top instead of being overwritten + private val loadMutex = Mutex() + + private val iconMap = ConcurrentHashMap() @Volatile private var stores = emptyList() @@ -167,7 +172,7 @@ class ExtensionManager( * again, so one can move between loaded and not loaded in either direction, while extensions * that still pass keep the instances they already had. */ - private suspend fun loadExtensions() { + private suspend fun loadExtensions() = loadMutex.withLock { try { val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value) @@ -197,7 +202,7 @@ class ExtensionManager( fetchExtensions() } catch (e: Exception) { logcat(LogPriority.ERROR, e) - withUIContext { context.toast(MR.strings.extension_api_error) } + withContext(Dispatchers.Main) { context.toast(MR.strings.extension_api_error) } return } @@ -229,7 +234,7 @@ class ExtensionManager( } private suspend fun fetchExtensions(): List { - return withIOContext { extensionStoreRepository.fetchExtensions() } + return extensionStoreRepository.fetchExtensions() } private fun setAvailableExtensions(extensions: List) { @@ -340,7 +345,7 @@ class ExtensionManager( * @param extension The extension to be registered. */ private fun registerExtension(extension: Extension.Loaded) { - loadedExtensionMapFlow.value += extension + loadedExtensionMapFlow.update { it + extension } } /** @@ -350,8 +355,8 @@ class ExtensionManager( * @param pkgName The package name of the uninstalled application. */ private fun unregisterExtension(pkgName: String) { - loadedExtensionMapFlow.value -= pkgName - notLoadedExtensionMapFlow.value -= pkgName + loadedExtensionMapFlow.update { it - pkgName } + notLoadedExtensionMapFlow.update { it - pkgName } } /** @@ -359,19 +364,19 @@ class ExtensionManager( */ private inner class InstallationListener : ExtensionInstallReceiver.Listener { - override fun onExtensionLoaded(extension: Extension.Loaded) { + override suspend fun onExtensionLoaded(extension: Extension.Loaded) = loadMutex.withLock { registerExtension(extension) - notLoadedExtensionMapFlow.value -= extension.pkgName + notLoadedExtensionMapFlow.update { it - extension.pkgName } refreshStatuses() } - override fun onExtensionNotLoaded(extension: Extension.NotLoaded) { - loadedExtensionMapFlow.value -= extension.pkgName - notLoadedExtensionMapFlow.value += extension + override suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded) = loadMutex.withLock { + loadedExtensionMapFlow.update { it - extension.pkgName } + notLoadedExtensionMapFlow.update { it + extension } refreshStatuses() } - override fun onPackageUninstalled(pkgName: String) { + override suspend fun onPackageUninstalled(pkgName: String) = loadMutex.withLock { ExtensionLoader.uninstallPrivateExtension(context, pkgName) unregisterExtension(pkgName) updatePendingUpdatesCount() @@ -385,19 +390,23 @@ class ExtensionManager( private fun refreshStatuses() { val available = availableExtensionListFlow.value if (available.isNotEmpty()) { - loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) -> - val listing = extension.findListing(available) - extension.copy( - hasUpdate = extension.findUpdate(available) != null, - isObsolete = listing == null, - store = if (stores.isEmpty()) extension.store else extension.pickStore(), - ) + loadedExtensionMapFlow.update { current -> + current.mapValues { (_, extension) -> + val listing = extension.findListing(available) + extension.copy( + hasUpdate = extension.findUpdate(available) != null, + isObsolete = listing == null, + store = if (stores.isEmpty()) extension.store else extension.pickStore(), + ) + } } - notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) -> - extension.copy( - hasUpdate = extension.findUpdate(available) != null, - store = if (stores.isEmpty()) extension.store else extension.pickStore(), - ) + notLoadedExtensionMapFlow.update { current -> + current.mapValues { (_, extension) -> + extension.copy( + hasUpdate = extension.findUpdate(available) != null, + store = if (stores.isEmpty()) extension.store else extension.pickStore(), + ) + } } } updatePendingUpdatesCount() diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt index 0125f2365..bf7e9a89b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt @@ -15,6 +15,11 @@ import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getUriSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.util.system.logcat @@ -22,6 +27,8 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic private val packageInstaller = service.packageManager.packageInstaller + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val packageActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) @@ -68,6 +75,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic } } + @Volatile private var activeSession: Pair? = null // Always ready @@ -76,40 +84,43 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic override fun processEntry(entry: Entry) { super.processEntry(entry) activeSession = null - try { - val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - } - activeSession = entry to packageInstaller.createSession(installParams) - val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException() - installParams.setSize(fileSize) - - val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException() - val session = packageInstaller.openSession(activeSession!!.second) - val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize) - session.use { - arrayOf(inputStream, outputStream).use { - inputStream.copyTo(outputStream) - session.fsync(outputStream) + scope.launch { + try { + val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) } - service.contentResolver.delete(entry.uri, null, null) + activeSession = entry to packageInstaller.createSession(installParams) + val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException() + installParams.setSize(fileSize) - val intentSender = PendingIntent.getBroadcast( - service, - activeSession!!.second, - Intent(INSTALL_ACTION).setPackage(service.packageName), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, - ).intentSender - @SuppressLint("RequestInstallPackagesPolicy") - session.commit(intentSender) + val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException() + val session = packageInstaller.openSession(activeSession!!.second) + val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize) + session.use { + arrayOf(inputStream, outputStream).use { + inputStream.copyTo(outputStream) + session.fsync(outputStream) + } + service.contentResolver.delete(entry.uri, null, null) + + val intentSender = PendingIntent.getBroadcast( + service, + activeSession!!.second, + Intent(INSTALL_ACTION).setPackage(service.packageName), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, + ).intentSender + @SuppressLint("RequestInstallPackagesPolicy") + session.commit(intentSender) + } + } catch (e: Exception) { + logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } + activeSession?.let { (_, sessionId) -> + // Already abandoned when the entry was cancelled mid-copy + runCatching { packageInstaller.abandonSession(sessionId) } + } + if (getActiveEntry() == entry) continueQueue(InstallStep.Error) } - } catch (e: Exception) { - logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - activeSession?.let { (_, sessionId) -> - packageInstaller.abandonSession(sessionId) - } - continueQueue(InstallStep.Error) } } @@ -130,6 +141,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic override fun onDestroy() { service.unregisterReceiver(packageActionReceiver) + scope.cancel() super.onDestroy() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt index 2e989337a..7d01b26c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import logcat.LogPriority import mihon.app.shizuku.IShellInterface import mihon.app.shizuku.ShellInterface @@ -117,14 +118,16 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { override fun processEntry(entry: Entry) { super.processEntry(entry) - try { - service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use { - shellInterface?.install(it, statusIntent.intentSender) + scope.launch { + try { + service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use { + shellInterface?.install(it, statusIntent.intentSender) + } + service.contentResolver.delete(entry.uri, null, null) + } catch (e: Exception) { + logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } + continueQueue(InstallStep.Error) } - service.contentResolver.delete(entry.uri, null, null) - } catch (e: Exception) { - logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - continueQueue(InstallStep.Error) } } 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 7c3d27c14..bf857eb4c 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 @@ -8,7 +8,9 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.model.Extension +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.util.system.logcat @@ -25,6 +27,22 @@ internal class ExtensionInstallReceiver( private val scope: CoroutineScope, ) : BroadcastReceiver() { + // Handled one at a time in arrival order, so a quick removal can't overtake the load of an earlier install + private val events = Channel(Channel.UNLIMITED) + + init { + scope.launch { + for (event in events) { + try { + handle(event) + } catch (e: Exception) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) { "Failed to handle $event" } + } + } + } + } + fun register(context: Context) { ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) } @@ -49,31 +67,14 @@ internal class ExtensionInstallReceiver( when (intent.action) { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> { if (isReplacing(intent)) return - - scope.launch { - when (val extension = getExtensionFromIntent(context, intent)) { - is Extension.Loaded -> listener.onExtensionLoaded(extension) - is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension) - null -> {} - } - } + getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) } } Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { - scope.launch { - when (val extension = getExtensionFromIntent(context, intent)) { - is Extension.Loaded -> listener.onExtensionLoaded(extension) - is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension) - null -> {} - } - } + getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) } } Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> { if (isReplacing(intent)) return - - val pkgName = getPackageNameFromIntent(intent) - if (pkgName != null) { - listener.onPackageUninstalled(pkgName) - } + getPackageNameFromIntent(intent)?.let { events.trySend(Event.Removed(it)) } } } } @@ -87,19 +88,20 @@ internal class ExtensionInstallReceiver( return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) } - /** - * Returns the extension triggered by the given intent. - * - * @param context The application context. - * @param intent The intent containing the package name of the extension. - */ - private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): Extension.Installed? { - val pkgName = getPackageNameFromIntent(intent) - if (pkgName == null) { - logcat(LogPriority.WARN) { "Package name not found" } - return null + private suspend fun handle(event: Event) { + when (event) { + is Event.Installed -> when ( + val extension = ExtensionLoader.loadExtensionFromPkgName( + event.context, + event.pkgName, + ) + ) { + is Extension.Loaded -> listener.onExtensionLoaded(extension) + is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension) + null -> {} + } + is Event.Removed -> listener.onPackageUninstalled(event.pkgName) } - return ExtensionLoader.loadExtensionFromPkgName(context, pkgName) } /** @@ -113,9 +115,14 @@ internal class ExtensionInstallReceiver( * Listener that receives extension installation events. */ interface Listener { - fun onExtensionLoaded(extension: Extension.Loaded) - fun onExtensionNotLoaded(extension: Extension.NotLoaded) - fun onPackageUninstalled(pkgName: String) + suspend fun onExtensionLoaded(extension: Extension.Loaded) + suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded) + suspend fun onPackageUninstalled(pkgName: String) + } + + private sealed interface Event { + data class Installed(val context: Context, val pkgName: String) : Event + data class Removed(val pkgName: String) : Event } companion object { 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 ef1a819ff..ea8be1009 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 @@ -11,21 +11,26 @@ import eu.kanade.tachiyomi.extension.installer.Installer import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.isPackageInstalled +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import logcat.LogPriority import mihon.core.metro.AppCoroutineScope import okhttp3.OkHttpClient import okhttp3.Request import tachiyomi.core.common.util.system.logcat import java.io.File +import java.util.concurrent.ConcurrentHashMap /** * The installer which installs, updates and uninstalls the extensions. @@ -40,11 +45,11 @@ class ExtensionInstaller( networkHelper: NetworkHelper, ) { - private val activeJobs = mutableMapOf() - private val activeSteps = mutableMapOf>() + private val activeJobs = ConcurrentHashMap() + private val activeSteps = ConcurrentHashMap>() private val extensionInstaller = basePreferences.extensionInstaller - private val httpClient: OkHttpClient = networkHelper.client + private val httpClient: OkHttpClient by lazy { networkHelper.client } /** * Adds the given extension to the downloads queue and returns an observable containing its @@ -68,14 +73,17 @@ class ExtensionInstaller( try { step.value = InstallStep.Downloading val request = Request.Builder().url(extension.apkUrl).build() - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - throw Exception("Failed to download extension") - } - response.body.byteStream().use { input -> - tmpFile.outputStream().use { output -> - input.copyTo(output) + httpClient.newCall(request).await().use { response -> + if (!response.isSuccessful) { + throw Exception("Failed to download extension") + } + // Interruptible so cancelInstall stops the body read too + runInterruptible { + response.body.byteStream().use { input -> + tmpFile.outputStream().use { output -> + input.copyTo(output) + } + } } } @@ -87,15 +95,13 @@ class ExtensionInstaller( throw Exception("Extension isn't signed with the key of ${extension.store.name}") } + ensureActive() step.value = InstallStep.Installing installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled) } catch (e: Exception) { - if (e is InterruptedException) { - // Canceled - } else { - logcat(LogPriority.INFO, e) - step.value = InstallStep.Error - } + if (e is CancellationException) throw e + logcat(LogPriority.INFO, e) + step.value = InstallStep.Error } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 859dbcb75..c51d4716f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -12,14 +12,15 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import mihon.data.dalvik.DelegateLastClassLoaderCompat import mihon.domain.extension.model.ContentWarning import mihon.domain.extension.model.ExtensionStore -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import java.io.File @@ -147,15 +148,16 @@ internal object ExtensionLoader { } ?.filter { isPackageAnExtension(it) } ?.map { ExtensionInfo(packageInfo = it, isShared = false) } - ?: emptySequence() + ?.toList() + .orEmpty() + val privateExtPkgsByName = privateExtPkgs.groupBy { it.packageInfo.packageName } val extPkgs = (sharedExtPkgs + privateExtPkgs) // Remove duplicates. Shared takes priority than private by default .distinctBy { it.packageInfo.packageName } // Compare version number .mapNotNull { sharedPkg -> - val privatePkg = privateExtPkgs - .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName } + val privatePkg = privateExtPkgsByName[sharedPkg.packageInfo.packageName]?.singleOrNull() selectExtensionPackage(sharedPkg, privatePkg) } .toList() @@ -163,7 +165,7 @@ internal object ExtensionLoader { if (extPkgs.isEmpty()) return emptyList() // Load each extension concurrently and wait for completion - return withIOContext { + return withContext(Dispatchers.IO) { extPkgs .map { async { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterViewModel.kt index 8e069035f..d41923629 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterViewModel.kt @@ -11,6 +11,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.source.interactor.ToggleLanguage import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import logcat.LogPriority @@ -49,6 +51,7 @@ class ExtensionFilterViewModel( logcat(LogPriority.ERROR, throwable) _events.send(ExtensionFilterEvent.FailedFetchingLanguages) } + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), ExtensionFilterState.Loading) fun toggle(language: String) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt index e31546440..e63f6b353 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import kotlin.time.Duration.Companion.seconds @@ -104,7 +103,7 @@ class ExtensionsViewModel( } } } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) val state: StateFlow = combine( @@ -127,7 +126,7 @@ class ExtensionsViewModel( init { - viewModelScope.launchIO { findAvailableExtensions() } + viewModelScope.launch(Dispatchers.IO) { findAvailableExtensions() } } fun searchQueryPredicate(query: String): (Extension) -> Boolean { @@ -165,7 +164,7 @@ class ExtensionsViewModel( } fun updateAllExtensions() { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { state.value.items.values.flatten() .map { it.extension } .filterIsInstance() @@ -175,13 +174,13 @@ class ExtensionsViewModel( } fun installExtension(extension: Extension.Available) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { extensionManager.installExtension(extension).collectToInstallUpdate(extension) } } fun updateExtension(extension: Extension.Installed) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { extensionManager.updateExtension(extension).collectToInstallUpdate(extension) } } @@ -211,7 +210,7 @@ class ExtensionsViewModel( } fun findAvailableExtensions() { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { isRefreshing.update { true } extensionManager.findAvailableExtensions() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt index 09d48dbfb..d9e239faa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/SourcePreferencesScreen.kt @@ -39,7 +39,9 @@ import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mihon.app.di.appGraph import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.screens.LoadingScreen @@ -145,7 +147,8 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) if (source is ConfigurableSource) { - val dataStore = SharedPreferencesDataStore(source.sourcePreferences()) + val sourcePreferences = withContext(Dispatchers.IO) { source.sourcePreferences().also { it.all } } + val dataStore = SharedPreferencesDataStore(sourcePreferences) preferenceManager.preferenceDataStore = dataStore source.setupPreferenceScreen(sourceScreen) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaViewModel.kt index 119fffac1..98fd67a54 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaViewModel.kt @@ -71,7 +71,7 @@ class MigrateMangaViewModel( ) { titleList, selection -> State(source = source.await(), selection = selection, titleList = titleList) } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) fun toggleSelection(item: Manga) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterViewModel.kt index ac2de235b..57b33ea01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterViewModel.kt @@ -12,11 +12,13 @@ import eu.kanade.domain.source.interactor.GetLanguagesWithSources import eu.kanade.domain.source.interactor.ToggleLanguage import eu.kanade.domain.source.interactor.ToggleSource import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import tachiyomi.domain.source.model.Source import java.util.SortedMap @@ -44,6 +46,7 @@ class SourcesFilterViewModel( ) } .catch { throwable -> emit(State.Error(throwable = throwable)) } + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) fun toggleSource(source: Source) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt index 968aea271..abcdf163d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -45,9 +45,11 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceViewModel.Listing import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.FilterList @@ -55,7 +57,6 @@ import mihon.icons.materialsymbols.rounded.Verified import mihon.icons.materialsymbols.roundedfilled.Favorite import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.core.common.Constants -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold @@ -224,7 +225,7 @@ data class BrowseSourceScreen( onLocalSourceHelpClick = onHelpClick, onMangaClick = { navigator.push((MangaScreen(it.id, true))) }, onMangaLongClick = { manga -> - scope.launchIO { + scope.launch(Dispatchers.IO) { val duplicates = viewModel.getDuplicateLibraryManga(manga) when { manga.favorite -> viewModel.setDialog(BrowseSourceViewModel.Dialog.RemoveManga(manga)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceViewModel.kt index 3dbc62730..7d080a72b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceViewModel.kt @@ -29,6 +29,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -41,9 +43,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.model.Category @@ -64,6 +66,7 @@ import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter class BrowseSourceViewModel( @Assisted private val sourceId: Long, @Assisted listingQuery: String?, + @AppCoroutineScope private val appScope: CoroutineScope, private val sourceManager: SourceManager, sourcePreferences: SourcePreferences, private val libraryPreferences: LibraryPreferences, @@ -94,7 +97,7 @@ class BrowseSourceViewModel( private val source: Source? get() = state.value.source init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val source = sourceManager.getOrStub(sourceId) state.update { @@ -234,7 +237,7 @@ class BrowseSourceViewModel( * @param manga the manga to update. */ fun changeMangaFavorite(manga: Manga) { - viewModelScope.launch { + appScope.launch { val update = if (manga.favorite) { val coverLastModified = manga.removeCovers(coverCache).coverLastModified MangaUpdate(manga.id) { @@ -307,7 +310,7 @@ class BrowseSourceViewModel( } fun moveMangaToCategories(manga: Manga, categoryIds: List) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { setMangaCategories.await( mangaId = manga.id, categoryIds = categoryIds.toList(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt index b5c3c629e..86c6474ac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mihon.domain.manga.model.toDomainManga import tachiyomi.core.common.preference.toggle -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.model.Manga @@ -139,7 +138,7 @@ abstract class SearchViewModel( searchJob?.cancel() - searchJob = viewModelScope.launchIO { + searchJob = viewModelScope.launch(Dispatchers.IO) { val sources = getSelectedSources() // Reuse previous results if possible @@ -205,8 +204,8 @@ abstract class SearchViewModel( } fun setMigrateDialog(currentId: Long, target: Manga) { - viewModelScope.launchIO { - val current = getManga.await(currentId) ?: return@launchIO + viewModelScope.launch(Dispatchers.IO) { + val current = getManga.await(currentId) ?: return@launch state.update { it.copy(dialog = Dialog.Migrate(target, current)) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryViewModel.kt index 49e29f131..823c53dcd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryViewModel.kt @@ -9,6 +9,8 @@ import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding import dev.zacsweers.metrox.viewmodel.ViewModelKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import tachiyomi.domain.category.interactor.CreateCategoryWithName import tachiyomi.domain.category.interactor.DeleteCategory import tachiyomi.domain.category.interactor.GetCategories @@ -32,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class CategoryViewModel( + @AppCoroutineScope private val appScope: CoroutineScope, private val getCategories: GetCategories, private val createCategoryWithName: CreateCategoryWithName, private val deleteCategory: DeleteCategory, @@ -66,7 +70,8 @@ class CategoryViewModel( fun deleteCategory(categoryId: Long) { viewModelScope.launch { - when (deleteCategory.await(categoryId = categoryId)) { + // The deletion finishes even if the screen is left while it runs + when (appScope.async { deleteCategory.await(categoryId = categoryId) }.await()) { is DeleteCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError) else -> {} } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkViewModel.kt index aa9b732f9..0f0a166f6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/deeplink/DeepLinkViewModel.kt @@ -14,12 +14,13 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.online.ResolvableSource import eu.kanade.tachiyomi.source.online.UriType +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import mihon.domain.manga.model.toDomainManga import mihon.domain.source.interactor.UpdateMangaFromRemote -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.interactor.NetworkToLocalManga @@ -46,7 +47,7 @@ class DeepLinkViewModel( } init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val source = sourceManager.getAll() .filterIsInstance() .firstOrNull { it.getUriType(query) != UriType.Unknown } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt index fccaab845..235dc63b4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueScreen.kt @@ -47,11 +47,12 @@ import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.NestedMenuItem import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.databinding.DownloadListBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.Sort import mihon.icons.materialsymbols.roundedfilled.Pause import mihon.icons.materialsymbols.roundedfilled.PlayArrow -import tachiyomi.core.common.util.lang.launchUI import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.Pill import tachiyomi.presentation.core.components.material.Scaffold @@ -258,11 +259,11 @@ object DownloadQueueScreen : Screen() { ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true) - scope.launchUI { + scope.launch(Dispatchers.Main) { viewModel.getDownloadStatusFlow() .collect(viewModel::onStatusChange) } - scope.launchUI { + scope.launch(Dispatchers.Main) { viewModel.getDownloadProgressFlow() .collect(viewModel::onUpdateDownloadedPages) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt index c30dd85d2..cdaf7b2f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt @@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.databinding.DownloadListBinding import eu.kanade.tachiyomi.source.model.Page +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted @@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -44,6 +46,7 @@ class DownloadQueueViewModel( } } } + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) lateinit var controllerBinding: DownloadListBinding @@ -149,19 +152,19 @@ class DownloadQueueViewModel( } fun pauseDownloads() { - downloadManager.pauseDownloads() + viewModelScope.launch { downloadManager.pauseDownloads() } } fun clearQueue() { - downloadManager.clearQueue() + viewModelScope.launch { downloadManager.clearQueue() } } fun reorder(downloads: List) { - downloadManager.reorderQueue(downloads) + viewModelScope.launch { downloadManager.reorderQueue(downloads) } } fun cancel(downloads: List) { - downloadManager.cancelQueuedDownloads(downloads) + viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) } } fun > reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt index d086aaeb5..b1dd5d7d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt @@ -14,6 +14,7 @@ import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.presentation.history.HistoryUiModel import eu.kanade.tachiyomi.util.lang.toLocalDate +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -32,10 +33,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories @@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class HistoryViewModel( + @AppCoroutineScope private val appScope: CoroutineScope, private val addTracks: AddTracks, private val getCategories: GetCategories, private val getDuplicateLibraryManga: GetDuplicateLibraryManga, @@ -88,7 +89,7 @@ class HistoryViewModel( _events.send(Event.InternalError) } .map { it.toHistoryUiModels() } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) @@ -115,11 +116,11 @@ class HistoryViewModel( } suspend fun getNextChapter(): Chapter? { - return withIOContext { getNextChapters.await(onlyUnread = false).firstOrNull() } + return getNextChapters.await(onlyUnread = false).firstOrNull() } fun getNextChapterForManga(mangaId: Long, chapterId: Long) { - viewModelScope.launchIO { + viewModelScope.launch { sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false)) } } @@ -130,21 +131,21 @@ class HistoryViewModel( } fun removeFromHistory(history: HistoryWithRelations) { - viewModelScope.launchIO { + viewModelScope.launch { removeHistory.await(history) } } fun removeAllFromHistory(mangaId: Long) { - viewModelScope.launchIO { + viewModelScope.launch { removeHistory.await(mangaId) } } fun removeAllHistory() { - viewModelScope.launchIO { + viewModelScope.launch { val result = removeHistory.awaitAll() - if (!result) return@launchIO + if (!result) return@launch _events.send(Event.HistoryCleared) } } @@ -172,7 +173,7 @@ class HistoryViewModel( } private fun moveMangaToCategory(mangaId: Long, categoryIds: List) { - viewModelScope.launchIO { + viewModelScope.launch { setMangaCategories.await(mangaId, categoryIds) } } @@ -181,7 +182,7 @@ class HistoryViewModel( moveMangaToCategory(manga.id, categories) if (manga.favorite) return - viewModelScope.launchIO { + viewModelScope.launch { updateManga.awaitUpdateFavorite(manga.id, true) } } @@ -192,13 +193,13 @@ class HistoryViewModel( } fun addFavorite(mangaId: Long) { - viewModelScope.launchIO { - val manga = getManga.await(mangaId) ?: return@launchIO + viewModelScope.launch { + val manga = getManga.await(mangaId) ?: return@launch val duplicates = getDuplicateLibraryManga(manga) if (duplicates.isNotEmpty()) { dialog.update { Dialog.DuplicateManga(manga, duplicates) } - return@launchIO + return@launch } addFavorite(manga) @@ -206,7 +207,7 @@ class HistoryViewModel( } fun addFavorite(manga: Manga) { - viewModelScope.launchIO { + viewModelScope.launch { // Move to default category if applicable val categories = getCategories() val defaultCategoryId = libraryPreferences.defaultCategory.get().toLong() @@ -216,14 +217,14 @@ class HistoryViewModel( // Default category set defaultCategory != null -> { val result = updateManga.awaitUpdateFavorite(manga.id, true) - if (!result) return@launchIO + if (!result) return@launch moveMangaToCategory(manga.id, defaultCategory) } // Automatic 'Default' or no categories defaultCategoryId == 0L || categories.isEmpty() -> { val result = updateManga.awaitUpdateFavorite(manga.id, true) - if (!result) return@launchIO + if (!result) return@launch moveMangaToCategory(manga.id, null) } @@ -232,7 +233,7 @@ class HistoryViewModel( } // Sync with tracking services if applicable - addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source)) + appScope.launch { addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source)) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsViewModel.kt index dbea4718b..8a0d51cbb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsViewModel.kt @@ -10,10 +10,10 @@ import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.data.track.TrackerManager import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.getAndSet -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.category.interactor.SetDisplayMode import tachiyomi.domain.category.interactor.SetSortModeForCategory import tachiyomi.domain.category.model.Category @@ -55,7 +55,7 @@ class LibrarySettingsViewModel( } fun setSort(category: Category?, mode: LibrarySort.Type, direction: LibrarySort.Direction) { - viewModelScope.launchIO { + viewModelScope.launch { setSortModeForCategory.await(category, mode, direction) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index eaec50b0f..be6af8d27 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -50,7 +50,6 @@ import mihon.feature.migration.config.MigrationConfigScreen import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.Help import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.category.model.Category import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.Manga @@ -93,7 +92,7 @@ data object LibraryTab : Tab { val snackbarHostState = remember { SnackbarHostState() } - val onClickRefresh: (Category?) -> Boolean = { category -> + val onClickRefresh: suspend (Category?) -> Boolean = { category -> val started = LibraryUpdateWorker.startNow(context.workManager, category) scope.launch { val msgRes = when { @@ -121,8 +120,8 @@ data object LibraryTab : Tab { onClickSelectAll = viewModel::selectAll, onClickInvertSelection = viewModel::invertSelection, onClickFilter = viewModel::showSettingsDialog, - onClickRefresh = { onClickRefresh(state.activeCategory) }, - onClickGlobalUpdate = { onClickRefresh(null) }, + onClickRefresh = { scope.launch { onClickRefresh(state.activeCategory) } }, + onClickGlobalUpdate = { scope.launch { onClickRefresh(null) } }, onClickOpenRandomManga = { scope.launch { val randomItem = viewModel.getRandomLibraryItemForCurrentCategory() @@ -189,7 +188,7 @@ data object LibraryTab : Tab { onChangeCurrentPage = viewModel::updateActiveCategoryIndex, onClickManga = { navigator.push(MangaScreen(it)) }, onContinueReadingClicked = { it: LibraryManga -> - scope.launchIO { + scope.launch { val chapter = viewModel.getNextUnreadChapter(it.manga) if (chapter != null) { context.startActivity( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt index b2aa78393..619b49e68 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt @@ -27,6 +27,7 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -42,14 +43,15 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mihon.core.common.utils.mutate +import mihon.core.metro.AppCoroutineScope import mihon.domain.library.model.search.QueryNode import mihon.feature.library.matches import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.util.lang.compareToWithCollator -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.model.Category @@ -77,6 +79,7 @@ import kotlin.time.Duration.Companion.seconds @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class LibraryViewModel( + @AppCoroutineScope private val appScope: CoroutineScope, private val getLibraryManga: GetLibraryManga, private val getCategories: GetCategories, private val getTracksPerManga: GetTracksPerManga, @@ -175,7 +178,7 @@ class LibraryViewModel( .applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds), ) } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) val state: StateFlow = combine( @@ -482,7 +485,8 @@ class LibraryViewModel( } suspend fun getNextUnreadChapter(manga: Manga): Chapter? { - return getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true).getNextUnread(manga, downloadManager) + val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) + return withContext(Dispatchers.Default) { chapters.getNextUnread(manga, downloadManager) } } /** @@ -502,7 +506,7 @@ class LibraryViewModel( private fun downloadNextChapters(amount: Int?) { val mangas = selectedManga - viewModelScope.launchNonCancellable { + appScope.launch { mangas.forEach { manga -> val chapters = getNextChapters.await(manga.id) .fastFilterNot { chapter -> @@ -524,7 +528,7 @@ class LibraryViewModel( private fun downloadBookmarkedChapters() { val mangas = selectedManga - viewModelScope.launchNonCancellable { + appScope.launch { mangas.forEach { manga -> val chapters = getBookmarkedChaptersByMangaId.await(manga.id) .fastFilterNot { chapter -> @@ -547,7 +551,7 @@ class LibraryViewModel( */ fun markReadSelection(read: Boolean) { val selection = selectedManga - viewModelScope.launchNonCancellable { + appScope.launch { selection.forEach { manga -> setReadStatus.await( manga = manga, @@ -566,7 +570,7 @@ class LibraryViewModel( * @param deleteChapters whether to delete downloaded chapters. */ fun removeMangas(mangas: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { - viewModelScope.launchNonCancellable { + appScope.launch { if (deleteFromLibrary) { val toDelete = mangas.map { it.removeCovers(coverCache) @@ -596,7 +600,7 @@ class LibraryViewModel( * @param removeCategories the categories to remove in all mangas. */ fun setMangaCategories(mangaList: List, addCategories: List, removeCategories: List) { - viewModelScope.launchNonCancellable { + appScope.launch { mangaList.forEach { manga -> val categoryIds = getCategoryIds(manga) .subtract(removeCategories.toSet()) @@ -721,7 +725,7 @@ class LibraryViewModel( } fun openChangeCategoryDialog() { - viewModelScope.launchIO { + viewModelScope.launch { // Create a copy of selected manga val mangaList = selectedManga diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 4222b0b69..18fc942f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -95,6 +95,7 @@ import eu.kanade.tachiyomi.util.system.isBenchmarkBuildType import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim import eu.kanade.tachiyomi.util.system.updaterEnabled import eu.kanade.tachiyomi.util.view.setComposeContent +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.callbackFlow @@ -115,7 +116,6 @@ import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.OpenInNew import mihon.icons.materialsymbols.roundedfilled.VolunteerActivism import tachiyomi.core.common.Constants -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.release.interactor.GetApplicationRelease @@ -302,7 +302,7 @@ class MainActivity : BaseActivity() { setSplashScreenExitAnimation(splashScreen) if (isLaunch && libraryPreferences.autoClearChapterCache.get()) { - lifecycleScope.launchIO { + lifecycleScope.launch(Dispatchers.IO) { chapterCache.clear() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverViewModel.kt index 797722d18..3c7aaa6f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverViewModel.kt @@ -31,11 +31,9 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.model.Manga @@ -87,7 +85,7 @@ class MangaCoverViewModel( viewModelScope.launch { try { val uri = saveCoverInternal(context, temp = true) ?: return@launch - withUIContext { + withContext(Dispatchers.Main) { context.startActivity(uri.toShareIntent(context)) } } catch (e: Throwable) { @@ -113,11 +111,11 @@ class MangaCoverViewModel( .size(Size.ORIGINAL) .build() - return withIOContext { + return withContext(Dispatchers.IO) { val result = context.imageLoader.execute(req).image?.asDrawable(context.resources) // TODO: Handle animated cover - val bitmap = result?.getBitmapOrNull() ?: return@withIOContext null + val bitmap = result?.getBitmapOrNull() ?: return@withContext null imageSaver.save( Image.Cover( bitmap = bitmap, @@ -136,7 +134,7 @@ class MangaCoverViewModel( */ fun editCover(context: Context, data: Uri) { val manga = state.value ?: return - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { context.contentResolver.openInputStream(data)?.use { try { manga.editCover(coverManager, it, updateManga, coverCache) @@ -149,7 +147,7 @@ class MangaCoverViewModel( } fun deleteCustomCover(context: Context) { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { try { coverCache.deleteCustomCover(mangaId) updateManga.awaitUpdateCoverLastModified(mangaId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 581944f3d..15ffab2c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -51,11 +51,12 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.feature.migration.config.MigrationConfigScreen import mihon.feature.migration.dialog.MigrateMangaDialog -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.manga.model.Manga @@ -94,7 +95,7 @@ class MangaScreen( LaunchedEffect(successState.manga, viewModel.source) { if (isHttpSource) { try { - withIOContext { + withContext(Dispatchers.IO) { assistUrl = getMangaUrl(viewModel.manga, viewModel.source) } } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt index a2181a2ed..459ff7ad8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt @@ -46,6 +46,7 @@ import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -62,20 +63,20 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.source.interactor.UpdateMangaFromRemote import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.mapAsCheckboxState -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories @@ -105,6 +106,7 @@ import kotlin.time.Duration.Companion.seconds class MangaViewModel( @Assisted private val mangaId: Long, @Assisted private val isFromSource: Boolean, + @AppCoroutineScope private val appScope: CoroutineScope, private val context: Context, private val libraryPreferences: LibraryPreferences, trackPreferences: TrackPreferences, @@ -190,7 +192,7 @@ class MangaViewModel( private var hasPromptedToAddBefore = false - private val defaultChapterFlagsJob = viewModelScope.launchIO { + private val defaultChapterFlagsJob = viewModelScope.launch { val manga = getMangaAndChapters.awaitManga(mangaId) if (!manga.favorite) { setMangaDefaultChapterFlags.await(manga) @@ -272,18 +274,25 @@ class MangaViewModel( hideMissingChapters = hideMissingChapters, ) } + // Composition would otherwise build these on first read, on the main thread, for every download progress tick + .onEach { + it.processedChapters + it.chapterListItems + it.isAnySelected + } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) init { - viewModelScope.launchIO { + // Progress ticks often, and each one copies the map of download states + viewModelScope.launch(Dispatchers.Default) { merge(downloadManager.statusFlow(), downloadManager.progressFlow()) .filter { it.manga.id == mangaId } .catch { logcat(LogPriority.ERROR, it) } .collect(::updateDownloadState) } - viewModelScope.launchIO { + viewModelScope.launch { // The state loads the entry and its chapters anyway, so they aren't queried separately val loadedState = state.filterIsInstance().first() val manga = loadedState.manga @@ -324,19 +333,17 @@ class MangaViewModel( fetchChapters: Boolean, ) { try { - withUIContext { - val update = updateMangaFromRemote( - source = sourceManager.getOrStub(manga.source), - manga = manga, - fetchDetails = fetchDetails, - fetchChapters = fetchChapters, - manualFetch = manualFetch, - ) - .getOrThrow() + val update = updateMangaFromRemote( + source = sourceManager.getOrStub(manga.source), + manga = manga, + fetchDetails = fetchDetails, + fetchChapters = fetchChapters, + manualFetch = manualFetch, + ) + .getOrThrow() - if (manualFetch) { - downloadNewChapters(update.newChapters) - } + if (manualFetch) { + downloadNewChapters(update.newChapters) } } catch (_: CancellationException) { // ignore @@ -382,7 +389,7 @@ class MangaViewModel( checkDuplicate: Boolean = true, ) { val state = successState ?: return - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val manga = state.manga if (isFavorited) { @@ -392,7 +399,7 @@ class MangaViewModel( if (manga.removeCovers(coverCache) != manga) { updateManga.awaitUpdateCoverLastModified(manga.id) } - withUIContext { onRemoved() } + onRemoved() } } else { // Add to library @@ -402,7 +409,7 @@ class MangaViewModel( if (duplicates.isNotEmpty()) { dialog.value = Dialog.DuplicateManga(manga, duplicates) - return@launchIO + return@launch } } @@ -414,14 +421,14 @@ class MangaViewModel( // Default category set defaultCategory != null -> { val result = updateManga.awaitUpdateFavorite(manga.id, true) - if (!result) return@launchIO + if (!result) return@launch moveMangaToCategory(defaultCategory) } // Automatic 'Default' or no categories defaultCategoryId == 0L || categories.isEmpty() -> { val result = updateManga.awaitUpdateFavorite(manga.id, true) - if (!result) return@launchIO + if (!result) return@launch moveMangaToCategory(null) } @@ -430,7 +437,7 @@ class MangaViewModel( } // Finally match with enhanced tracking when available - addTracks.bindEnhancedTrackers(manga, state.source) + appScope.launch { addTracks.bindEnhancedTrackers(manga, state.source) } } } } @@ -453,7 +460,7 @@ class MangaViewModel( } fun setFetchInterval(manga: Manga, interval: Int) { - viewModelScope.launchIO { + viewModelScope.launch { updateManga.awaitUpdateFetchInterval( // Custom intervals are negative manga.copy(fetchInterval = -interval), @@ -474,7 +481,7 @@ class MangaViewModel( */ private fun deleteDownloads() { val state = successState ?: return - downloadManager.deleteManga(state.manga, state.source) + appScope.launch { downloadManager.deleteManga(state.manga, state.source) } } /** @@ -501,7 +508,7 @@ class MangaViewModel( moveMangaToCategory(categories) if (manga.favorite) return - viewModelScope.launchIO { + viewModelScope.launch { updateManga.awaitUpdateFavorite(manga.id, true) } } @@ -517,7 +524,7 @@ class MangaViewModel( } private fun moveMangaToCategory(categoryIds: List) { - viewModelScope.launchIO { + viewModelScope.launch { setMangaCategories.await(mangaId, categoryIds) } } @@ -645,15 +652,18 @@ class MangaViewModel( startNow: Boolean, ) { if (successState == null) return + val startNowId = if (startNow) chapters.singleOrNull()?.id ?: return else null - viewModelScope.launchNonCancellable { - if (startNow) { - val chapterId = chapters.singleOrNull()?.id ?: return@launchNonCancellable - downloadManager.startDownloadNow(chapterId) - } else { - downloadChapters(chapters) - toggleAllSelection(false) - } + viewModelScope.launch { + // The queueing outlives the screen, the add to library prompt doesn't + appScope.launch { + if (startNowId != null) { + downloadManager.startDownloadNow(startNowId) + } else { + downloadChapters(chapters) + } + }.join() + if (startNowId == null) toggleAllSelection(false) if (!isFavorited && !hasPromptedToAddBefore) { hasPromptedToAddBefore = true @@ -710,8 +720,10 @@ class MangaViewModel( private fun cancelDownload(chapterId: Long) { val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return - downloadManager.cancelQueuedDownloads(listOf(activeDownload)) - updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) + viewModelScope.launch { + downloadManager.cancelQueuedDownloads(listOf(activeDownload)) + updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) + } } fun markPreviousChapterRead(pointer: Chapter) { @@ -730,14 +742,17 @@ class MangaViewModel( fun markChaptersRead(chapters: List, read: Boolean) { toggleAllSelection(false) if (chapters.isEmpty()) return - viewModelScope.launchIO { - setReadStatus.await( - read = read, - chapters = chapters.toTypedArray(), - ) + viewModelScope.launch { + // The read state is written even if the screen closes, the tracking prompt isn't + appScope.launch { + setReadStatus.await( + read = read, + chapters = chapters.toTypedArray(), + ) + }.join() if (!read || successState?.hasLoggedInTrackers == false || autoTrackState == AutoTrackState.NEVER) { - return@launchIO + return@launch } refreshTrackers() @@ -746,13 +761,11 @@ class MangaViewModel( val maxChapterNumber = chapters.maxOf { it.chapterNumber } val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead } - if (!shouldPromptTrackingUpdate) return@launchIO + if (!shouldPromptTrackingUpdate) return@launch if (autoTrackState == AutoTrackState.ALWAYS) { trackChapter.await(context, mangaId, maxChapterNumber) - withUIContext { - context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt())) - } - return@launchIO + context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt())) + return@launch } val result = snackbarHostState.showSnackbar( @@ -775,7 +788,7 @@ class MangaViewModel( logcat(LogPriority.ERROR, e) { "Failed to refresh track data mangaId=$mangaId for service ${track!!.id}" } - withUIContext { + withContext(Dispatchers.Main) { context.toast( context.stringResource( MR.strings.track_error, @@ -801,7 +814,7 @@ class MangaViewModel( * @param chapters the list of chapters to bookmark. */ fun bookmarkChapters(chapters: List, bookmarked: Boolean) { - viewModelScope.launchIO { + viewModelScope.launch { chapters .filterNot { it.bookmark == bookmarked } .map { ChapterUpdate(it.id) { bookmark = bookmarked } } @@ -816,24 +829,20 @@ class MangaViewModel( * @param chapters the list of chapters to delete. */ fun deleteChapters(chapters: List) { - viewModelScope.launchNonCancellable { + val state = successState ?: return + // Outlives the screen, which the delete dialog usually closes along with + appScope.launch { try { - successState?.let { state -> - downloadManager.deleteChapters( - chapters, - state.manga, - state.source, - ) - } - } catch (e: Throwable) { + downloadManager.deleteChapters(chapters, state.manga, state.source) + } catch (e: Exception) { logcat(LogPriority.ERROR, e) } } } private fun downloadNewChapters(chapters: List) { - viewModelScope.launchNonCancellable { - val manga = successState?.manga ?: return@launchNonCancellable + appScope.launch { + val manga = successState?.manga ?: return@launch val chaptersToDownload = filterChaptersForDownload.await(manga, chapters) if (chaptersToDownload.isNotEmpty()) { @@ -854,7 +863,7 @@ class MangaViewModel( TriState.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ } - viewModelScope.launchNonCancellable { + appScope.launch { setMangaChapterFlags.awaitSetUnreadFilter(manga, flag) } } @@ -872,7 +881,7 @@ class MangaViewModel( TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED } - viewModelScope.launchNonCancellable { + appScope.launch { setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag) } } @@ -890,7 +899,7 @@ class MangaViewModel( TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED } - viewModelScope.launchNonCancellable { + appScope.launch { setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag) } } @@ -902,7 +911,7 @@ class MangaViewModel( fun setDisplayMode(mode: Long) { val manga = successState?.manga ?: return - viewModelScope.launchNonCancellable { + appScope.launch { setMangaChapterFlags.awaitSetDisplayMode(manga, mode) } } @@ -914,25 +923,27 @@ class MangaViewModel( fun setSorting(sort: Long) { val manga = successState?.manga ?: return - viewModelScope.launchNonCancellable { + appScope.launch { setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort) } } fun setCurrentSettingsAsDefault(applyToExisting: Boolean) { val manga = successState?.manga ?: return - viewModelScope.launchNonCancellable { - libraryPreferences.setChapterSettingsDefault(manga) - if (applyToExisting) { - setMangaDefaultChapterFlags.awaitAll() - } + viewModelScope.launch { + appScope.launch { + libraryPreferences.setChapterSettingsDefault(manga) + if (applyToExisting) { + setMangaDefaultChapterFlags.awaitAll() + } + }.join() snackbarHostState.showSnackbar(message = context.stringResource(MR.strings.chapter_settings_updated)) } } fun resetToDefaultSettings() { val manga = successState?.manga ?: return - viewModelScope.launchNonCancellable { + viewModelScope.launch { setMangaDefaultChapterFlags.await(manga) } } @@ -1046,7 +1057,7 @@ class MangaViewModel( } fun setExcludedScanlators(excludedScanlators: Set) { - viewModelScope.launchIO { + viewModelScope.launch { setExcludedScanlators.await(mangaId, excludedScanlators) } } @@ -1127,11 +1138,12 @@ class MangaViewModel( val unreadFilter = manga.unreadFilter val downloadedFilter = manga.downloadedFilter val bookmarkedFilter = manga.bookmarkedFilter + val chapterSort = getChapterSort(manga) return asSequence() .filter { (chapter) -> applyFilter(unreadFilter) { !chapter.read } } .filter { (chapter) -> applyFilter(bookmarkedFilter) { chapter.bookmark } } .filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalManga } } - .sortedWith { (chapter1), (chapter2) -> getChapterSort(manga).invoke(chapter1, chapter2) } + .sortedWith { (chapter1), (chapter2) -> chapterSort(chapter1, chapter2) } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/notes/MangaNotesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/notes/MangaNotesScreen.kt index 4601002a6..93ce198ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/notes/MangaNotesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/notes/MangaNotesScreen.kt @@ -18,10 +18,13 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel import eu.kanade.presentation.manga.MangaNotesScreen import eu.kanade.presentation.util.Screen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import tachiyomi.core.common.util.lang.launchNonCancellable +import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import tachiyomi.domain.manga.interactor.UpdateMangaNotes import tachiyomi.domain.manga.model.Manga @@ -45,6 +48,7 @@ class MangaNotesScreen( @AssistedInject class Model( @Assisted private val manga: Manga, + @AppCoroutineScope private val appScope: CoroutineScope, private val updateMangaNotes: UpdateMangaNotes, ) : ViewModel() { @@ -65,7 +69,8 @@ class MangaNotesScreen( it.copy(notes = content) } - viewModelScope.launchNonCancellable { + // On Main so the debounced saves and the one on leaving are written in order + appScope.launch(Dispatchers.Main.immediate) { updateMangaNotes(manga.id, content) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt index ec47a2a08..c201f79ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -59,6 +59,8 @@ import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -67,16 +69,15 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import logcat.LogPriority import mihon.app.di.appGraph +import mihon.core.metro.AppCoroutineScope import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.roundedfilled.Delete import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.source.service.SourceManager @@ -201,6 +202,7 @@ data class TrackInfoDialogHomeScreen( class Model( @Assisted private val mangaId: Long, @Assisted private val sourceId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, private val context: Context, private val getTracks: GetTracks, private val getManga: GetManga, @@ -235,13 +237,13 @@ data class TrackInfoDialogHomeScreen( fun registerEnhancedTracking(item: TrackItem) { item.tracker as EnhancedTracker - viewModelScope.launchNonCancellable { - val manga = getManga.await(mangaId) ?: return@launchNonCancellable + appScope.launch { + val manga = getManga.await(mangaId) ?: return@launch try { val matchResult = item.tracker.match(manga) ?: throw Exception() item.tracker.register(matchResult, mangaId) } catch (_: Exception) { - withUIContext { + withContext(Dispatchers.Main) { context.toast(MR.strings.error_no_match) } } @@ -255,7 +257,7 @@ data class TrackInfoDialogHomeScreen( logcat(LogPriority.ERROR, e) { "Failed to refresh track data mangaId=$mangaId for service ${track!!.id}" } - withUIContext { + withContext(Dispatchers.Main) { context.toast( context.stringResource( MR.strings.track_error, @@ -268,7 +270,7 @@ data class TrackInfoDialogHomeScreen( } fun togglePrivate(item: TrackItem) { - viewModelScope.launchNonCancellable { + appScope.launch { item.tracker.setRemotePrivate(item.track!!.toDbTrack(), !item.track.private) } } @@ -316,6 +318,7 @@ data class TrackStatusSelectorScreen( class Model( @Assisted private val track: Track, @Assisted private val trackerId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -340,7 +343,7 @@ data class TrackStatusSelectorScreen( } fun setStatus() { - viewModelScope.launchNonCancellable { + appScope.launch { tracker.setRemoteStatus(track.toDbTrack(), state.value.selection) } } @@ -379,6 +382,7 @@ data class TrackChapterSelectorScreen( class Model( @Assisted private val track: Track, @Assisted private val trackerId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -408,7 +412,7 @@ data class TrackChapterSelectorScreen( } fun setChapter() { - viewModelScope.launchNonCancellable { + appScope.launch { tracker.setRemoteLastChapterRead(track.toDbTrack(), state.value.selection) } } @@ -447,6 +451,7 @@ data class TrackScoreSelectorScreen( class Model( @Assisted private val track: Track, @Assisted private val trackerId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -475,7 +480,7 @@ data class TrackScoreSelectorScreen( } fun setScore() { - viewModelScope.launchNonCancellable { + appScope.launch { tracker.setRemoteScore(track.toDbTrack(), state.value.selection) } } @@ -574,6 +579,7 @@ data class TrackDateSelectorScreen( @Assisted private val track: Track, @Assisted private val trackerId: Long, @Assisted private val start: Boolean, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -597,7 +603,7 @@ data class TrackDateSelectorScreen( fun setDate(millis: Long) { // Convert to local time val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault()) - viewModelScope.launchNonCancellable { + appScope.launch { if (start) { tracker.setRemoteStartDate(track.toDbTrack(), localMillis) } else { @@ -678,6 +684,7 @@ data class TrackDateRemoverScreen( @Assisted private val track: Track, @Assisted private val trackerId: Long, @Assisted private val start: Boolean, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -693,7 +700,7 @@ data class TrackDateRemoverScreen( fun getServiceName() = tracker.name fun removeDate() { - viewModelScope.launchNonCancellable { + appScope.launch { if (start) { tracker.setRemoteStartDate(track.toDbTrack(), 0) } else { @@ -749,6 +756,7 @@ data class TrackerSearchScreen( @Assisted private val currentUrl: String?, @Assisted initialQuery: String, @Assisted private val trackerId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, trackerManager: TrackerManager, ) : ViewModel() { @@ -783,7 +791,7 @@ data class TrackerSearchScreen( // To show loading state state.update { it.copy(queryResult = null, selected = null) } - val result = withIOContext { + val result = withContext(Dispatchers.IO) { try { val results = tracker.search(query) Result.success(results) @@ -801,7 +809,7 @@ data class TrackerSearchScreen( } fun registerTracking(item: TrackSearch) { - viewModelScope.launchNonCancellable { tracker.register(item, mangaId) } + appScope.launch { tracker.register(item, mangaId) } } fun updateSelection(selected: TrackSearch) { @@ -895,6 +903,7 @@ data class TrackerRemoveScreen( @Assisted private val mangaId: Long, @Assisted private val track: Track, @Assisted private val trackerId: Long, + @AppCoroutineScope private val appScope: CoroutineScope, private val deleteTrack: DeleteTrack, trackerManager: TrackerManager, ) : ViewModel() { @@ -913,7 +922,7 @@ data class TrackerRemoveScreen( fun isDeletable() = tracker is DeletableTracker fun deleteMangaFromService() { - viewModelScope.launchNonCancellable { + appScope.launch { try { (tracker as DeletableTracker).delete(track) } catch (e: Exception) { @@ -923,7 +932,7 @@ data class TrackerRemoveScreen( } fun unregisterTracking(serviceId: Long) { - viewModelScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) } + appScope.launch { deleteTrack.await(mangaId, serviceId) } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt index f19728405..0dfe6cfb0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt @@ -34,8 +34,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import mihon.feature.support.SupportUsScreen -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource @@ -95,7 +95,7 @@ class MoreViewModel( init { // Handle running/paused status change and queue progress updating - viewModelScope.launchIO { + viewModelScope.launch { combine( downloadManager.isDownloaderRunning, downloadManager.queueState, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt index 32b66c05b..9454bf359 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt @@ -21,13 +21,14 @@ import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.saveTo import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import java.io.File @@ -60,7 +61,7 @@ class NewUpdateScreenModel( downloadJob = viewModelScope.launch { state.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) } try { - withIOContext { downloadApk() } + withContext(Dispatchers.IO) { downloadApk() } state.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) } } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 385fa85d7..108a9e3fc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -81,6 +81,7 @@ import eu.kanade.tachiyomi.util.system.readerBackgroundColor import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -95,8 +96,6 @@ import mihon.app.di.AppGraph import mihon.core.metro.metroGraph import tachiyomi.core.common.Constants import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import tachiyomi.presentation.core.util.collectAsState @@ -345,9 +344,7 @@ class ReaderActivity : BaseActivity() { } override fun onPause() { - lifecycleScope.launchNonCancellable { - viewModel.updateHistory() - } + viewModel.saveHistory() super.onPause() } @@ -621,7 +618,7 @@ class ReaderActivity : BaseActivity() { binding.readerContainer.removeView(loadingIndicator) viewModel.state.value.viewer?.setChapters(viewerChapters) - lifecycleScope.launchIO { + lifecycleScope.launch(Dispatchers.IO) { viewModel.getChapterUrl()?.let { url -> assistUrl = url } @@ -706,7 +703,7 @@ class ReaderActivity : BaseActivity() { * the viewer is reaching the beginning or end of a chapter or the transition page is active. */ fun requestPreloadChapter(chapter: ReaderChapter) { - lifecycleScope.launchIO { viewModel.preload(chapter) } + lifecycleScope.launch(Dispatchers.IO) { viewModel.preload(chapter) } } /** 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 34802f989..1b2edc09d 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 @@ -56,6 +56,8 @@ import eu.kanade.tachiyomi.util.storage.cacheImageDir import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -67,14 +69,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext 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 -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.UpdateChapter @@ -191,25 +191,32 @@ class ReaderViewModel( */ private var chapterReadStartTime: Instant? = null + // Serializes the progress writes, which outlive the reader, so they are applied in order + private val progressWrites = Mutex() + private var chapterToDownload: Download? = null - private val unfilteredChapterList by lazy { - val manga = manga!! - runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) } + private var unfilteredChapterList: List? = null + + private suspend fun getUnfilteredChapterList(): List { + return unfilteredChapterList + ?: getChaptersByMangaId.await(manga!!.id, applyScanlatorFilter = false) + .also { unfilteredChapterList = it } } /** - * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first - * time in a background thread to avoid blocking the UI. + * Chapter list for the active manga. Loaded by [init]. */ - private val chapterList by lazy { + private lateinit var chapterList: List + + private suspend fun loadChapterList(): List { val manga = manga!! - val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) } + val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) .let { filtered -> if (filtered.any { it.id == chapterId }) { filtered } else { - filtered + unfilteredChapterList.filter { it.id == chapterId } + filtered + getUnfilteredChapterList().filter { it.id == chapterId } } } @@ -260,7 +267,7 @@ class ReaderViewModel( else -> chapters } - chaptersForReader + return chaptersForReader .sortedWith(getChapterSort(manga, sortDescending = false)) .run { if (readerPreferences.skipDupe.get()) { @@ -308,7 +315,8 @@ class ReaderViewModel( if (currentChapters != null) { currentChapters.unref() chapterToDownload?.let { - downloadManager.addDownloadsToStartOfQueue(listOf(it)) + // viewModelScope is already cancelled here + appScope.launch { downloadManager.addDownloadsToStartOfQueue(listOf(it)) } } } } @@ -327,7 +335,7 @@ class ReaderViewModel( * Failures are reported through [State.initError]. */ private suspend fun init() { - withIOContext { + withContext(Dispatchers.IO) { try { val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found") val source = sourceManager.getOrStub(manga.source) @@ -345,6 +353,7 @@ class ReaderViewModel( source, ) + chapterList = loadChapterList() loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) } catch (e: Throwable) { if (e is CancellationException) { @@ -372,13 +381,14 @@ class ReaderViewModel( chapterList.getOrNull(chapterPos + 1), ) - withUIContext { + withContext(Dispatchers.Main) { + val toDownload = cancelQueuedDownloads(newChapters.currChapter) mutableState.update { // Add new references first to avoid unnecessary recycling newChapters.ref() it.viewerChapters?.unref() - chapterToDownload = cancelQueuedDownloads(newChapters.currChapter) + chapterToDownload = toDownload it.copy( viewerChapters = newChapters, bookmarked = newChapters.currChapter.chapter.bookmark, @@ -395,7 +405,7 @@ class ReaderViewModel( private fun loadNewChapter(chapter: ReaderChapter) { val loader = loader ?: return - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { logcat { "Loading ${chapter.chapter.url}" } updateHistory() @@ -422,7 +432,7 @@ class ReaderViewModel( mutableState.update { it.copy(isLoadingAdjacentChapter = true) } try { - withIOContext { + withContext(Dispatchers.IO) { loadChapter(loader, chapter) } } catch (e: Throwable) { @@ -497,8 +507,8 @@ class ReaderViewModel( val selectedChapter = page.chapter val pages = selectedChapter.pages ?: return - // Save last page read and mark as read if needed - viewModelScope.launchNonCancellable { + // Save last page read and mark as read if needed. The database calls are main-safe. + appScope.launch(Dispatchers.Main.immediate) { updateChapterProgress(selectedChapter, page) } @@ -523,7 +533,7 @@ class ReaderViewModel( if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val isNextChapterDownloaded = downloadManager.isChapterDownloaded( nextChapter.name, nextChapter.scanlator, @@ -531,7 +541,7 @@ class ReaderViewModel( manga.title, manga.source, ) - if (!isNextChapterDownloaded) return@launchIO + if (!isNextChapterDownloaded) return@launch val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id).run { if (readerPreferences.skipDupe.get()) { @@ -552,7 +562,7 @@ class ReaderViewModel( * Removes [currentChapter] from download queue * if setting is enabled and [currentChapter] is queued for download */ - private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? { + private suspend fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? { return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id)?.also { downloadManager.cancelQueuedDownloads(listOf(it)) } @@ -563,7 +573,7 @@ class ReaderViewModel( * If both conditions are satisfied enqueues chapter for delete * @param currentChapter current chapter, which is going to be marked as read. */ - private fun deleteChapterIfNeeded(currentChapter: ReaderChapter) { + private suspend fun deleteChapterIfNeeded(currentChapter: ReaderChapter) { val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots.get() if (removeAfterReadSlots == -1) return @@ -579,7 +589,7 @@ class ReaderViewModel( val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead.get() .contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING) val duplicateChapters = if (markDuplicateAsRead) { - unfilteredChapterList + getUnfilteredChapterList() .filter { it.id != chapterToDelete.chapter.id && it.isRecognizedNumber && @@ -609,17 +619,22 @@ class ReaderViewModel( if (!incognitoMode && page.status !is Page.State.Error) { val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) } + val isLastPage = readerChapter.pages?.lastIndex == pageIndex - if (readerChapter.pages?.lastIndex == pageIndex) { - updateChapterProgressOnComplete(readerChapter) + // Taken before the first suspension, and the mutex is fair, so the writes land in the order the pages + // were selected even when one of them suspends longer + progressWrites.withLock { + if (isLastPage) { + updateChapterProgressOnComplete(readerChapter) + } + + updateChapter.await( + ChapterUpdate(readerChapter.chapter.id) { + this.read = read + lastPageRead = pageIndex + }, + ) } - - updateChapter.await( - ChapterUpdate(readerChapter.chapter.id) { - this.read = read - lastPageRead = pageIndex - }, - ) } } @@ -631,7 +646,7 @@ class ReaderViewModel( .contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING) if (!markDuplicateAsRead) return - val duplicateUnreadChapters = unfilteredChapterList + val duplicateUnreadChapters = getUnfilteredChapterList() .mapNotNull { chapter -> if ( !chapter.read && @@ -650,10 +665,18 @@ class ReaderViewModel( chapterReadStartTime = Clock.System.now() } + /** + * Saves the chapter last read history on the app scope, so it's written even when the reader is closing. + */ + fun saveHistory() { + // Immediate so the read time is taken when the reader pauses + appScope.launch(Dispatchers.Main.immediate) { updateHistory() } + } + /** * Saves the chapter last read history if incognito mode isn't on. */ - suspend fun updateHistory() { + private suspend fun updateHistory() { getCurrentChapter()?.let { readerChapter -> if (incognitoMode) return@let @@ -711,7 +734,8 @@ class ReaderViewModel( val bookmarked = !readerChapter.chapter.bookmark readerChapter.update { it.copy(bookmark = bookmarked) } - viewModelScope.launchNonCancellable { + // On Main so quick toggles are saved in order + viewModelScope.launch { updateChapter.await( ChapterUpdate(readerChapter.chapter.id) { bookmark = bookmarked @@ -738,25 +762,25 @@ class ReaderViewModel( } } + private var readingModeJob: Job? = null + /** * Updates the viewer position for the open manga. */ fun setMangaReadingMode(readingMode: ReadingMode) { val manga = manga ?: return - runBlocking(Dispatchers.IO) { + // Only the latest pick matters, and letting an older one finish last would restore its choice + readingModeJob?.cancel() + readingModeJob = viewModelScope.launch(Dispatchers.IO) { setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong()) + val updatedManga = getManga.await(manga.id) val currChapters = state.value.viewerChapters if (currChapters != null) { // Save current page val currChapter = currChapters.currChapter currChapter.requestedPage = currChapter.chapter.lastPageRead - mutableState.update { - it.copy( - manga = getManga.await(manga.id), - viewerChapters = currChapters, - ) - } + mutableState.update { it.copy(manga = updatedManga) } eventChannel.send(Event.ReloadViewerChapters) } } @@ -774,25 +798,25 @@ class ReaderViewModel( } } + private var orientationJob: Job? = null + /** * Updates the orientation type for the open manga. */ fun setMangaOrientationType(orientation: ReaderOrientation) { val manga = manga ?: return - viewModelScope.launchIO { + // Only the latest pick matters, and letting an older one finish last would restore its choice + orientationJob?.cancel() + orientationJob = viewModelScope.launch(Dispatchers.IO) { setMangaViewerFlags.awaitSetOrientation(manga.id, orientation.flagValue.toLong()) + val updatedManga = getManga.await(manga.id) val currChapters = state.value.viewerChapters if (currChapters != null) { // Save current page val currChapter = currChapters.currChapter currChapter.requestedPage = currChapter.chapter.lastPageRead - mutableState.update { - it.copy( - manga = getManga.await(manga.id), - viewerChapters = currChapters, - ) - } + mutableState.update { it.copy(manga = updatedManga) } eventChannel.send(Event.SetOrientation(getMangaOrientation())) eventChannel.send(Event.ReloadViewerChapters) } @@ -878,24 +902,25 @@ class ReaderViewModel( "" } - // Copy file in background. - viewModelScope.launchNonCancellable { - try { - val uri = imageSaver.save( - image = Image.Page( - inputStream = page.stream!!, - name = filename, - location = Location.Pictures.create(relativePath), - ), - ) - withUIContext { - notifier.onComplete(uri) - eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri))) + // The save and its notification outlive the reader, the result event doesn't + viewModelScope.launch { + val result = appScope.async { + try { + val uri = imageSaver.save( + image = Image.Page( + inputStream = page.stream!!, + name = filename, + location = Location.Pictures.create(relativePath), + ), + ) + withContext(Dispatchers.Main) { notifier.onComplete(uri) } + SaveImageResult.Success(uri) + } catch (e: Throwable) { + notifier.onError(e.message) + SaveImageResult.Error(e) } - } catch (e: Throwable) { - notifier.onError(e.message) - eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) - } + }.await() + eventChannel.send(Event.SavedImage(result)) } } @@ -915,8 +940,8 @@ class ReaderViewModel( val filename = generateFilename(manga, page) - try { - viewModelScope.launchNonCancellable { + viewModelScope.launch(Dispatchers.IO) { + try { destDir.deleteRecursively() val uri = imageSaver.save( image = Image.Page( @@ -926,9 +951,10 @@ class ReaderViewModel( ), ) eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page)) + } catch (e: Exception) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) } - } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) } } @@ -941,17 +967,19 @@ class ReaderViewModel( val manga = manga ?: return val stream = page.stream ?: return - viewModelScope.launchNonCancellable { - val result = try { - manga.editCover(coverManager, stream(), updateManga, coverCache) - if (manga.isLocal() || manga.favorite) { - SetAsCoverResult.Success - } else { - SetAsCoverResult.AddToLibraryFirst + viewModelScope.launch { + val result = appScope.async { + try { + manga.editCover(coverManager, stream(), updateManga, coverCache) + if (manga.isLocal() || manga.favorite) { + SetAsCoverResult.Success + } else { + SetAsCoverResult.AddToLibraryFirst + } + } catch (e: Exception) { + SetAsCoverResult.Error } - } catch (e: Exception) { - SetAsCoverResult.Error - } + }.await() eventChannel.send(Event.SetCoverResult(result)) } } @@ -977,7 +1005,8 @@ class ReaderViewModel( val manga = manga ?: return - viewModelScope.launchNonCancellable { + // Reading the last page and leaving right away is common, so it outlives the reader + appScope.launch { trackChapter.await(context, manga.id, readerChapter.chapter.chapterNumber) } } @@ -986,12 +1015,9 @@ class ReaderViewModel( * Enqueues these [chapters] to be deleted when [deletePendingChapters] is called. The download * manager handles persisting them across process deaths. */ - private fun enqueueDeleteChapters(chapters: List) { + private suspend fun enqueueDeleteChapters(chapters: List) { val manga = manga ?: return - - viewModelScope.launchNonCancellable { - downloadManager.enqueueChaptersToDelete(chapters, manga) - } + downloadManager.enqueueChaptersToDelete(chapters, manga) } /** @@ -999,7 +1025,9 @@ class ReaderViewModel( * are ignored. */ private fun deletePendingChapters() { - viewModelScope.launchNonCancellable { + appScope.launch { + // Progress writes started before leaving may still enqueue the chapter just finished + progressWrites.withLock {} downloadManager.deletePendingChapters() } } 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 519a9d3da..45ce04d60 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 @@ -9,10 +9,11 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.core.archive.archiveReader import mihon.core.archive.epubReader import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.model.StubSource @@ -43,7 +44,7 @@ class ChapterLoader( } chapter.state = ReaderChapter.State.Loading - withIOContext { + withContext(Dispatchers.IO) { logcat { "Loading pages for ${chapter.chapter.name}" } try { val loader = getPageLoader(chapter) @@ -80,7 +81,7 @@ class ChapterLoader( /** * Returns the page loader to use for this [chapter]. */ - private fun getPageLoader(chapter: ReaderChapter): PageLoader { + private suspend fun getPageLoader(chapter: ReaderChapter): PageLoader { val dbChapter = chapter.chapter val isDownloaded = downloadManager.isChapterDownloadedOnDisk( dbChapter.name, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index a38d7a493..fe3aca9e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -56,7 +56,7 @@ internal class DownloadPageLoader( return loader.getPages() } - private fun getPagesFromDirectory(): List { + private suspend fun getPagesFromDirectory(): List { val pages = downloadManager.buildPageList(source, manga, chapter.chapter) return pages.map { page -> ReaderPage(page.index, page.url, page.imageUrl) { 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 9df554339..e7a60652a 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 @@ -9,14 +9,16 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withIOContext +import kotlinx.coroutines.withContext import java.util.concurrent.PriorityBlockingQueue import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -42,8 +44,14 @@ internal class HttpPageLoader( private val preloadSize = 4 + // How many pending loadPage calls want each page index, counting their preloads. Pages are downloaded one + // at a time, so once nothing wants the page in flight its download is cancelled instead of holding up the + // pages being read. Guards inFlight too. + private val wantedPages = HashMap() + private var inFlight: Pair? = null + init { - scope.launchIO { + scope.launch(Dispatchers.IO) { flow { while (true) { emit(runInterruptible { queue.take() }) @@ -51,10 +59,17 @@ internal class HttpPageLoader( } .filter { it.page.status == Page.State.Queue } .collect { - internalLoadPage( - page = it.page, - force = it.priority == PriorityPage.RETRY, - ) + coroutineScope { + val load = launch { + internalLoadPage( + page = it.page, + force = it.priority == PriorityPage.RETRY, + ) + } + synchronized(wantedPages) { inFlight = it.page.index to load } + load.join() + synchronized(wantedPages) { inFlight = null } + } } } } @@ -89,7 +104,7 @@ internal class HttpPageLoader( // Check if the image has been deleted. Only this touches the disk; the queueing below // stays on the caller's thread, so requests queue in the order they were made. if (page.status == Page.State.Ready && imageUrl != null && - !withIOContext { chapterCache.isImageInCache(imageUrl) } + !withContext(Dispatchers.IO) { chapterCache.isImageInCache(imageUrl) } ) { page.status = Page.State.Queue } @@ -99,11 +114,23 @@ internal class HttpPageLoader( page.status = Page.State.Queue } + val nextPages = nextPages(page, preloadSize) + val wanted = listOf(page) + nextPages + synchronized(wantedPages) { + wanted.forEach { wantedPages.merge(it.index, 1, Int::plus) } + } + val queuedPages = mutableListOf() if (page.status == Page.State.Queue) { queuedPages += PriorityPage(page, PriorityPage.DEFAULT).also { queue.offer(it) } } - queuedPages += preloadNextPages(page, preloadSize) + queuedPages += nextPages.mapNotNull { + if (it.status == Page.State.Queue) { + PriorityPage(it, PriorityPage.ADJACENT).apply { queue.offer(this) } + } else { + null + } + } suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { @@ -112,6 +139,13 @@ internal class HttpPageLoader( queue.remove(it) } } + synchronized(wantedPages) { + wanted.forEach { + wantedPages.computeIfPresent(it.index) { _, count -> (count - 1).takeIf { it > 0 } } + } + // A cancelled load ends in Error, which the next loadPage of that page queues again + inFlight?.let { (index, load) -> if (index !in wantedPages) load.cancel() } + } } } } @@ -133,7 +167,7 @@ internal class HttpPageLoader( // Cache current page list progress for online chapters to allow a faster reopen chapter.pages?.let { pages -> - appScope.launchIO { + appScope.launch(Dispatchers.IO) { try { // Convert to pages without reader information val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } @@ -148,24 +182,14 @@ internal class HttpPageLoader( } /** - * Preloads the given [amount] of pages after the [currentPage] with a lower priority. - * - * @return a list of [PriorityPage] that were added to the [queue] + * Returns the [amount] of pages after [currentPage], which are preloaded with a lower priority. */ - private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List { + private fun nextPages(currentPage: ReaderPage, amount: Int): List { val pageIndex = currentPage.index val pages = currentPage.chapter.pages ?: return emptyList() if (pageIndex == pages.lastIndex) return emptyList() - return pages - .subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size)) - .mapNotNull { - if (it.status == Page.State.Queue) { - PriorityPage(it, PriorityPage.ADJACENT).apply { queue.offer(this) } - } else { - null - } - } + return pages.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size)) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt index 6ca0149f2..d42492b44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.AbstractComposeView import eu.kanade.presentation.reader.ChapterTransition @@ -30,19 +31,31 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) { data = if (manga != null && source != null) { + val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() } Data( transition = transition, currChapterDownloaded = transition.from.pageLoader?.isLocal == true, goingToChapterDownloaded = manga.isLocal() || - transition.to?.chapter?.let { goingToChapter -> + goingToChapter?.let { + downloadManager.isChapterDownloaded( + chapterName = it.name, + chapterScanlator = it.scanlator, + chapterUrl = it.url, + mangaTitle = manga.title, + sourceId = manga.source, + ) + } ?: false, + isGoingToChapterOnDisk = goingToChapter?.let { + { downloadManager.isChapterDownloadedOnDisk( - chapterName = goingToChapter.name, - chapterScanlator = goingToChapter.scanlator, - chapterUrl = goingToChapter.url, + chapterName = it.name, + chapterScanlator = it.scanlator, + chapterUrl = it.url, mangaTitle = manga.title, source = source, ) - } ?: false, + } + }, ) } else { null @@ -52,6 +65,9 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At @Composable override fun Content() { data?.let { + val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) { + it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() } + } TachiyomiTheme { CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.bodySmall, @@ -60,7 +76,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At ChapterTransition( transition = it.transition, currChapterDownloaded = it.currChapterDownloaded, - goingToChapterDownloaded = it.goingToChapterDownloaded, + goingToChapterDownloaded = goingToChapterDownloaded, ) } } @@ -71,5 +87,6 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At val transition: ChapterTransition, val currChapterDownloaded: Boolean, val goingToChapterDownloaded: Boolean, + val isGoingToChapterOnDisk: (suspend () -> Boolean)?, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 4a6850bf4..b1601bbac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -13,18 +13,17 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import logcat.LogPriority import okio.Buffer import okio.BufferedSource import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR @@ -94,7 +93,7 @@ class PagerPageHolder( val loader = page.chapter.pageLoader ?: return supervisorScope { - launchIO { + launch(Dispatchers.IO) { loader.loadPage(page) } page.statusFlow.collectLatest { state -> @@ -150,8 +149,10 @@ class PagerPageHolder( val streamFn = page.stream ?: return try { - val (source, isAnimated, background) = withIOContext { - val source = streamFn().use { process(item, Buffer().readFrom(it)) } + val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } } + // Splitting, merging and sampling the background decode the image, which is CPU work + val (source, isAnimated, background) = withContext(Dispatchers.Default) { + val source = process(item, buffer) val isAnimated = ImageUtil.isAnimatedAndSupported(source) val background = if (!isAnimated && viewer.config.automaticBackground) { ImageUtil.chooseBackground(context, source.peek().inputStream()) @@ -160,7 +161,7 @@ class PagerPageHolder( } Triple(source, isAnimated, background) } - withUIContext { + withContext(Dispatchers.Main) { setImage( source, isAnimated, @@ -179,7 +180,7 @@ class PagerPageHolder( } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) - withUIContext { + withContext(Dispatchers.Main) { setError(e) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt index 2e33b8aba..55262b47b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt @@ -482,7 +482,7 @@ open class WebGpuViewer( if (!chapterPreloadsInFlight.add(chapterId)) return } - scope.launch(Dispatchers.Default) { + scope.launch(Dispatchers.IO) { try { activity.viewModel.preload(chapter) repeat(25) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 5b63635d3..357cfbf5c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -18,18 +18,17 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.dpToPx +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import logcat.LogPriority import okio.Buffer import okio.BufferedSource import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR @@ -134,7 +133,7 @@ class WebtoonPageHolder( val page = page ?: return val loader = page.chapter.pageLoader ?: return supervisorScope { - launchIO { + launch(Dispatchers.IO) { loader.loadPage(page) } page.statusFlow.collectLatest { state -> @@ -190,12 +189,14 @@ class WebtoonPageHolder( val streamFn = page?.stream ?: return try { - val (source, isAnimated) = withIOContext { - val source = streamFn().use { process(Buffer().readFrom(it)) } + val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } } + // Splitting and merging decode the image, which is CPU work + val (source, isAnimated) = withContext(Dispatchers.Default) { + val source = process(buffer) val isAnimated = ImageUtil.isAnimatedAndSupported(source) Pair(source, isAnimated) } - withUIContext { + withContext(Dispatchers.Main) { frame.setImage( source, isAnimated, @@ -209,7 +210,7 @@ class WebtoonPageHolder( } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) - withUIContext { + withContext(Dispatchers.Main) { setError(e) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsViewModel.kt index e6e523fef..33fbde097 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsViewModel.kt @@ -15,10 +15,11 @@ import eu.kanade.presentation.more.stats.data.StatsData import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import tachiyomi.core.common.util.lang.launchIO +import kotlinx.coroutines.launch import tachiyomi.domain.history.interactor.GetTotalReadDuration import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.service.LibraryPreferences @@ -48,7 +49,7 @@ class StatsViewModel( private val loggedInTrackers by lazy { trackerManager.loggedInTrackers() } init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val libraryManga = getLibraryManga.await() val distinctLibraryManga = libraryManga.fastDistinctBy { it.id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt index bda0b232a..40f6bebe5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt @@ -24,6 +24,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateWorker import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.system.workManager +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -46,9 +47,8 @@ import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import logcat.LogPriority +import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.TriState -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.UpdateChapter @@ -67,6 +67,7 @@ import kotlin.time.Duration.Companion.seconds @ViewModelKey @ContributesIntoMap(AppScope::class, binding = binding()) class UpdatesViewModel( + @AppCoroutineScope private val appScope: CoroutineScope, private val context: Context, private val sourceManager: SourceManager, private val downloadManager: DownloadManager, @@ -103,7 +104,7 @@ class UpdatesViewModel( } init { - viewModelScope.launchIO { + viewModelScope.launch { merge(downloadManager.statusFlow(), downloadManager.progressFlow()) .catch { logcat(LogPriority.ERROR, it) } .collect(this@UpdatesViewModel::updateDownloadState) @@ -167,7 +168,7 @@ class UpdatesViewModel( .toUpdateItems() .applyFilters(itemPreferences) } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) val state: StateFlow = combine( @@ -199,6 +200,7 @@ class UpdatesViewModel( dialog = dialog, ) } + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) private fun List.applyFilters( @@ -242,11 +244,9 @@ class UpdatesViewModel( } } - fun updateLibrary(): Boolean { + suspend fun updateLibrary(): Boolean { val started = LibraryUpdateWorker.startNow(context.workManager) - viewModelScope.launch { - _events.send(Event.LibraryUpdateTriggered(started)) - } + _events.send(Event.LibraryUpdateTriggered(started)) return started } @@ -276,11 +276,11 @@ class UpdatesViewModel( } } - private fun startDownloadingNow(chapterId: Long) { + private suspend fun startDownloadingNow(chapterId: Long) { downloadManager.startDownloadNow(chapterId) } - private fun cancelDownload(chapterId: Long) { + private suspend fun cancelDownload(chapterId: Long) { val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return downloadManager.cancelQueuedDownloads(listOf(activeDownload)) updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) @@ -292,7 +292,7 @@ class UpdatesViewModel( * @param read whether to mark chapters as read or unread. */ fun markUpdatesRead(updates: List, read: Boolean) { - viewModelScope.launchIO { + appScope.launch { setReadStatus.await( read = read, chapters = updates @@ -308,7 +308,7 @@ class UpdatesViewModel( * @param updates the list of chapters to bookmark. */ fun bookmarkUpdates(updates: List, bookmark: Boolean) { - viewModelScope.launchIO { + viewModelScope.launch { updates .filterNot { it.update.bookmark == bookmark } .map { ChapterUpdate(it.update.chapterId) { this.bookmark = bookmark } } @@ -322,7 +322,7 @@ class UpdatesViewModel( * @param updatesItem the list of chapters to download. */ private fun downloadChapters(updatesItem: List) { - viewModelScope.launchNonCancellable { + appScope.launch { val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values for (updates in groupedUpdates) { val mangaId = updates.first().update.mangaId @@ -341,7 +341,7 @@ class UpdatesViewModel( * @param updatesItem list of chapters */ fun deleteChapters(updatesItem: List) { - viewModelScope.launchNonCancellable { + appScope.launch { updatesItem .groupBy { it.update.mangaId } .entries @@ -372,8 +372,8 @@ class UpdatesViewModel( val currentSelection = selectedChapterIds.value if ((item.update.chapterId in currentSelection) == selected) return - // Off the visible items, not the id set, which can retain ids filtered out of the list - val firstSelection = items.none { it.selected } + // Only visible items count, since the id set can retain ids filtered out of the list + val firstSelection = items.none { it.update.chapterId in currentSelection } val newSelection = currentSelection.toHashSet() newSelection.addOrRemove(item.update.chapterId, selected) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt index a5374c4a6..33724d1e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -20,6 +20,8 @@ import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import okhttp3.HttpUrl.Companion.toHttpUrl @@ -67,11 +69,13 @@ class WebViewActivity : BaseActivity() { // Null until the source it belongs to has been resolved val headers by produceState?>(initialValue = null) { val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource - value = try { - source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty() - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Failed to build headers" } - emptyMap() + value = withContext(Dispatchers.IO) { + try { + source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty() + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to build headers" } + emptyMap() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewViewModel.kt index 27ee37aba..d8003480f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewViewModel.kt @@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -43,7 +44,7 @@ class WebViewViewModel( field = MutableStateFlow?>(null) init { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { val source = sourceId?.let { sourceManager.get(it) as? HttpSource } headers.value = try { source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt index 256394789..0c50f6eb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt @@ -13,11 +13,11 @@ import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.offsetAt import kotlinx.datetime.toLocalDateTime -import tachiyomi.core.common.util.lang.withNonCancellableContext -import tachiyomi.core.common.util.lang.withUIContext import kotlin.time.Clock @Inject @@ -28,7 +28,7 @@ class CrashLogUtil( private val networkPreferences: NetworkPreferences, ) { - suspend fun dumpLogs(exception: Throwable? = null) = withNonCancellableContext { + suspend fun dumpLogs(exception: Throwable? = null) = withContext(Dispatchers.IO) { try { val file = context.createFileInCacheDir("mihon_crash_logs.txt") @@ -40,9 +40,9 @@ class CrashLogUtil( Runtime.getRuntime().exec("logcat *:$logPriority -d -v year -v zone -f ${file.absolutePath}").waitFor() val uri = file.getUriCompat(context) - context.startActivity(uri.toShareIntent(context, "text/plain")) + withContext(Dispatchers.Main) { context.startActivity(uri.toShareIntent(context, "text/plain")) } } catch (_: Throwable) { - withUIContext { context.toast("Failed to get logs") } + withContext(Dispatchers.Main) { context.toast("Failed to get logs") } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt index 984a3299a..532aa49a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt @@ -5,6 +5,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkInfo import androidx.work.WorkManager import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import logcat.LogPriority import tachiyomi.core.common.util.system.logcat import kotlin.time.Duration.Companion.seconds @@ -12,8 +13,8 @@ import kotlin.time.Duration.Companion.seconds val Context.workManager: WorkManager get() = WorkManager.getInstance(this) -fun WorkManager.isRunning(tag: String): Boolean { - val list = this.getWorkInfosByTag(tag).get() +suspend fun WorkManager.isRunning(tag: String): Boolean { + val list = getWorkInfosByTagFlow(tag).first() return list.any { it.state == WorkInfo.State.RUNNING } } diff --git a/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt index cfb8db2b6..fb2c3787c 100644 --- a/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt +++ b/app/src/main/java/mihon/core/migration/MigrationJobFactory.kt @@ -2,7 +2,6 @@ package mihon.core.migration import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import tachiyomi.core.common.util.system.logcat @@ -19,7 +18,7 @@ class MigrationJobFactory( logcat { "Running migration: { name = ${migration::class.simpleName}, version = ${migration.version} }" } - async(start = CoroutineStart.UNDISPATCHED) { + async { val prev = acc.await() migration(migrationContext) || prev } diff --git a/app/src/main/java/mihon/core/migration/migrations/CategoryPreferencesCleanupMigration.kt b/app/src/main/java/mihon/core/migration/migrations/CategoryPreferencesCleanupMigration.kt index f1aa735f9..d1b0fecd8 100644 --- a/app/src/main/java/mihon/core/migration/migrations/CategoryPreferencesCleanupMigration.kt +++ b/app/src/main/java/mihon/core/migration/migrations/CategoryPreferencesCleanupMigration.kt @@ -3,9 +3,10 @@ package mihon.core.migration.migrations import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.core.migration.Migration import mihon.core.migration.MigrationContext -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.library.service.LibraryPreferences @@ -19,7 +20,7 @@ class CategoryPreferencesCleanupMigration( ) : Migration { override val version: Float = 10f - override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withContext(Dispatchers.IO) { val allCategories = getCategories.await().map { it.id.toString() }.toSet() val defaultCategory = libraryPreferences.defaultCategory.get() @@ -40,6 +41,6 @@ class CategoryPreferencesCleanupMigration( if (garbageIds.isEmpty()) return@forEach preference.set(ids.minus(garbageIds)) } - return@withIOContext true + return@withContext true } } diff --git a/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt b/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt index 882710cb9..c019f6303 100644 --- a/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt +++ b/app/src/main/java/mihon/core/migration/migrations/TrustExtensionRepositoryMigration.kt @@ -4,11 +4,12 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.Inject import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.core.migration.Migration import mihon.core.migration.MigrationContext import mihon.domain.extension.repository.ExtensionStoreRepository -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat @Inject @@ -19,7 +20,7 @@ class TrustExtensionRepositoryMigration( ) : Migration { override val version: Float = 7f - override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withContext(Dispatchers.IO) { for ((index, source) in sourcePreferences.extensionRepos.get().withIndex()) { try { repository.insertFromPreference( @@ -31,6 +32,6 @@ class TrustExtensionRepositoryMigration( } } sourcePreferences.extensionRepos.delete() - return@withIOContext true + return@withContext true } } diff --git a/app/src/main/java/mihon/core/migration/migrations/VerticalNavigatorMigration.kt b/app/src/main/java/mihon/core/migration/migrations/VerticalNavigatorMigration.kt index f97d00559..b1a4273fe 100644 --- a/app/src/main/java/mihon/core/migration/migrations/VerticalNavigatorMigration.kt +++ b/app/src/main/java/mihon/core/migration/migrations/VerticalNavigatorMigration.kt @@ -5,10 +5,11 @@ import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.core.migration.Migration import mihon.core.migration.MigrationContext import tachiyomi.core.common.preference.PreferenceStore -import tachiyomi.core.common.util.lang.withIOContext @Inject @ContributesIntoSet(AppScope::class) @@ -18,7 +19,7 @@ class VerticalNavigatorMigration( ) : Migration { override val version: Float = 25f - override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withContext(Dispatchers.IO) { if (migrationContext.previousVersion == 24) { val oldVerticalNavigator = preferenceStore.getBoolean("pref_webtoon_vertical_navigator", true) if (oldVerticalNavigator.get()) { @@ -33,6 +34,6 @@ class VerticalNavigatorMigration( oldVerticalNavigatorOnLeft.delete() } - return@withIOContext true + return@withContext true } } diff --git a/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt b/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt index cc7f332c5..2bdd8e4a9 100644 --- a/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt +++ b/app/src/main/java/mihon/domain/migration/usecases/MigrateMangaUseCase.kt @@ -9,6 +9,9 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.track.EnhancedTracker import eu.kanade.tachiyomi.data.track.TrackerManager import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mihon.core.metro.AppCoroutineScope import mihon.domain.migration.models.MigrationFlag import mihon.domain.source.interactor.UpdateMangaFromRemote import tachiyomi.domain.category.interactor.GetCategories @@ -25,6 +28,7 @@ import kotlin.time.Clock @Inject class MigrateMangaUseCase( + @AppCoroutineScope private val scope: CoroutineScope, private val sourcePreferences: SourcePreferences, private val trackerManager: TrackerManager, private val sourceManager: SourceManager, @@ -102,12 +106,15 @@ class MigrateMangaUseCase( // Delete downloaded if (MigrationFlag.REMOVE_DOWNLOAD in flags && currentSource != null) { - downloadManager.deleteManga(current, currentSource) + // Not awaited, so a slow storage delete doesn't hold up the migration + scope.launch { downloadManager.deleteManga(current, currentSource) } } // Update custom cover (recheck if custom cover exists) if (MigrationFlag.CUSTOM_COVER in flags && current.hasCustomCover()) { - coverCache.setCustomCoverToCache(target, coverCache.getCustomCoverFile(current.id).inputStream()) + coverCache.getCustomCoverFile(current.id).inputStream().use { + coverCache.setCustomCoverToCache(target, it) + } } val currentMangaUpdate = MangaUpdate(current.id) { diff --git a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt index cacbdce87..d7e6d1660 100644 --- a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt +++ b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt @@ -9,9 +9,10 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.domain.source.models.RemoteMangaUpdate -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.repository.ChapterRepository @@ -58,18 +59,16 @@ class UpdateMangaFromRemote( fetchChapters: Boolean = false, manualFetch: Boolean = false, fetchWindow: ClosedRange? = null, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val chapters = chapterRepository.getChapterByMangaId(manga.id) .sortedBy { it.sourceOrder } - val update = withIOContext { - source.getMangaUpdate( - manga = manga.toSManga(), - chapters = chapters.map(Chapter::toSChapter), - fetchDetails = fetchDetails, - fetchChapters = fetchChapters, - ) - } + val update = source.getMangaUpdate( + manga = manga.toSManga(), + chapters = chapters.map(Chapter::toSChapter), + fetchDetails = fetchDetails, + fetchChapters = fetchChapters, + ) awaitUpdateFromSource(manga, update.manga, fetchDetails, manualFetch) val newChapters = syncChaptersWithSource.await( rawSourceChapters = update.chapters, diff --git a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt index ac6de0631..da1380f5e 100644 --- a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt +++ b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt @@ -49,9 +49,11 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import mihon.feature.migration.list.MigrationListScreen import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.automirroredrounded.ArrowForward @@ -62,7 +64,6 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState -import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR @@ -333,7 +334,7 @@ class MigrationConfigScreen(private val mangaIds: Collection) : Screen() { } init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { initSources() state.update { it.copy(isLoading = false) } } diff --git a/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt b/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt index 699e05741..06fe4af9e 100644 --- a/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt +++ b/app/src/main/java/mihon/feature/migration/dialog/MigrateMangaDialog.kt @@ -30,14 +30,15 @@ import eu.kanade.domain.manga.model.hasCustomCover import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mihon.domain.migration.models.MigrationFlag import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.feature.common.utils.getLabel -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.LabeledCheckbox @@ -105,9 +106,9 @@ internal fun Screen.MigrateMangaDialog( TextButton( onClick = { - scope.launchIO { + scope.launch(Dispatchers.IO) { viewModel.migrateManga(replace = false) - withUIContext { onComplete() } + withContext(Dispatchers.Main) { onComplete() } } }, ) { @@ -115,9 +116,9 @@ internal fun Screen.MigrateMangaDialog( } TextButton( onClick = { - scope.launchIO { + scope.launch(Dispatchers.IO) { viewModel.migrateManga(replace = true) - withUIContext { onComplete() } + withContext(Dispatchers.Main) { onComplete() } } }, ) { diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt index 3c8b0a99c..396922fec 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt @@ -14,6 +14,7 @@ import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.getNameForMangaInfo import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -26,16 +27,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.source.interactor.UpdateMangaFromRemote import mihon.feature.migration.list.models.MigratingManga import mihon.feature.migration.list.models.MigratingManga.SearchResult import mihon.feature.migration.list.search.SmartSourceSearchEngine -import tachiyomi.core.common.util.lang.launchIO -import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.manga.interactor.GetManga @@ -80,7 +81,7 @@ class MigrationListViewModel( private var migrateJob: Job? = null init { - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val manga = mangaIds .map { async { @@ -232,7 +233,7 @@ class MigrationListViewModel( fun useMangaForMigration(current: Long, target: Long, onMissingChapters: () -> Unit) { val migratingManga = items.find { it.manga.id == current } ?: return migratingManga.searchResult.value = SearchResult.Searching - viewModelScope.launchIO { + viewModelScope.launch(Dispatchers.IO) { val result = migratingManga.migrationScope.async { val manga = getManga.await(target) ?: return@async null try { @@ -246,8 +247,8 @@ class MigrationListViewModel( if (result == null) { migratingManga.searchResult.value = SearchResult.NotFound - withUIContext { onMissingChapters() } - return@launchIO + withContext(Dispatchers.Main) { onMissingChapters() } + return@launch } migratingManga.searchResult.value = result.toSuccessSearchResult() @@ -264,7 +265,7 @@ class MigrationListViewModel( } private fun migrateMangas(replace: Boolean) { - migrateJob = viewModelScope.launchIO { + migrateJob = viewModelScope.launch(Dispatchers.IO) { state.update { it.copy(dialog = Dialog.Progress(0f)) } val items = items try { @@ -308,9 +309,9 @@ class MigrationListViewModel( } fun migrateNow(mangaId: Long, replace: Boolean) { - viewModelScope.launchIO { - val manga = items.find { it.manga.id == mangaId } ?: return@launchIO - val target = (manga.searchResult.value as? SearchResult.Success)?.manga ?: return@launchIO + viewModelScope.launch(Dispatchers.IO) { + val manga = items.find { it.manga.id == mangaId } ?: return@launch + val target = (manga.searchResult.value as? SearchResult.Success)?.manga ?: return@launch migrateManga(current = manga.manga, target = target, replace = replace) removeManga(mangaId) @@ -318,8 +319,8 @@ class MigrationListViewModel( } fun removeManga(mangaId: Long) { - viewModelScope.launchIO { - val item = items.find { it.manga.id == mangaId } ?: return@launchIO + viewModelScope.launch(Dispatchers.IO) { + val item = items.find { it.manga.id == mangaId } ?: return@launch removeManga(item) item.migrationScope.cancel() updateMigrationProgress() diff --git a/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt b/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt index b0bc59514..e06862d44 100644 --- a/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt +++ b/app/src/main/java/mihon/feature/migration/list/search/BaseSmartSearchEngine.kt @@ -40,7 +40,7 @@ abstract class BaseSmartSearchEngine( ): T? { val eligibleManga = supervisorScope { queries.map { query -> - async(Dispatchers.Default) { + async(Dispatchers.IO) { val builtQuery = if (!extraSearchParams.isNullOrBlank()) { "$query $extraSearchParams" } else { diff --git a/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt b/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt index 19771c408..a1e6390d3 100644 --- a/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt +++ b/app/src/main/java/mihon/feature/migration/list/search/SmartSourceSearchEngine.kt @@ -2,6 +2,8 @@ package mihon.feature.migration.list.search import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.domain.manga.model.toDomainManga import tachiyomi.domain.manga.model.Manga @@ -21,7 +23,8 @@ class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngin } } + // Sources aren't main-safe: their default implementations block on the network private fun makeSearchAction(source: Source): SearchAction = { query -> - source.getSearchManga(1, query, source.getFilterList()).mangas + withContext(Dispatchers.IO) { source.getSearchManga(1, query, source.getFilterList()).mangas } } } diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt index 703d83f3c..04562362b 100644 --- a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt @@ -79,7 +79,7 @@ class UpcomingViewModel( } .distinctUntilChanged() .map { it.toUpcomingUIModels() } - .flowOn(Dispatchers.IO) + .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) val state: StateFlow = combine( diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index f9322e840..0e0f201c8 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -7,7 +7,7 @@ import okhttp3.HttpUrl class AndroidCookieJar : CookieJar { - private val manager = CookieManager.getInstance() + private val manager by lazy { CookieManager.getInstance() } override fun saveFromResponse(url: HttpUrl, cookies: List) { val urlString = url.toString() diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt index db7d37446..19bf47b90 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt @@ -5,7 +5,8 @@ import app.cash.quickjs.QuickJs import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import tachiyomi.core.common.util.lang.withIOContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Util for evaluating JavaScript in sources. @@ -23,7 +24,7 @@ class JavaScriptEngine(context: Context) { * @param script JavaScript to execute. * @return Result of JavaScript code as a primitive type. */ - suspend fun evaluate(script: String): T = withIOContext { + suspend fun evaluate(script: String): T = withContext(Dispatchers.Default) { QuickJs.create().use { it.evaluate(script) as T } 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 8eea836e7..e4357f268 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 @@ -26,7 +26,7 @@ class NetworkHelper( val cookieJar = AndroidCookieJar() - private val clientBuilder: OkHttpClient.Builder = run { + private fun clientBuilder(): OkHttpClient.Builder = run { val builder = OkHttpClient.Builder() .cookieJar(cookieJar) .connectTimeout(30.seconds) @@ -65,18 +65,20 @@ class NetworkHelper( } } - val client = clientBuilder - .addInterceptor( - CloudflareInterceptor(context, cookieJar, scope, ::defaultUserAgentProvider), - ) - .build() + val client: OkHttpClient by lazy { + clientBuilder() + .addInterceptor( + CloudflareInterceptor(context, cookieJar, scope, ::defaultUserAgentProvider), + ) + .build() + } /** * @deprecated Since extension-lib 1.5 */ @Deprecated("The regular client handles Cloudflare by default") @Suppress("UNUSED") - val cloudflareClient: OkHttpClient = client + val cloudflareClient: OkHttpClient get() = client fun defaultUserAgentProvider() = preferences.defaultUserAgent.get().trim() } 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 3187025a5..f505f80d2 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 @@ -11,11 +11,12 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response -import tachiyomi.core.common.util.lang.launchUI import tachiyomi.i18n.MR import java.util.Locale import java.util.concurrent.CountDownLatch @@ -59,7 +60,7 @@ abstract class WebViewInterceptor( } if (!WebViewUtil.supportsWebView(context)) { - scope.launchUI { + scope.launch(Dispatchers.Main) { 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 deleted file mode 100644 index 07421fc25..000000000 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/CoroutinesExtensions.kt +++ /dev/null @@ -1,30 +0,0 @@ -package tachiyomi.core.common.util.lang - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = - launch(Dispatchers.Main, block = block) - -fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = - launch(Dispatchers.IO, block = block) - -fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = - launchIO { withContext(NonCancellable, block) } - -suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext( - Dispatchers.Main, - block, -) - -suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext( - Dispatchers.IO, - block, -) - -suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = - withContext(NonCancellable, block) diff --git a/data/src/main/java/mihon/data/extension/service/ExtensionStoreService.kt b/data/src/main/java/mihon/data/extension/service/ExtensionStoreService.kt index f8ff5443e..bf2d8d09b 100644 --- a/data/src/main/java/mihon/data/extension/service/ExtensionStoreService.kt +++ b/data/src/main/java/mihon/data/extension/service/ExtensionStoreService.kt @@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.json.Json import kotlinx.serialization.json.okio.decodeFromBufferedSource @@ -30,7 +32,9 @@ class ExtensionStoreService( private val json: Json, private val protoBuf: ProtoBuf, ) { - suspend fun fetch(indexUrl: String): Result { + suspend fun fetch(indexUrl: String): Result = withContext(Dispatchers.IO) { fetchStore(indexUrl) } + + private suspend fun fetchStore(indexUrl: String): Result { var updatedIndexUrl: String = indexUrl return try { val response = network.client.newCall(GET(updatedIndexUrl)).awaitSuccess() @@ -56,7 +60,7 @@ class ExtensionStoreService( } if (networkStore is NetworkLegacyExtensionRepo && networkStore.indexV2 != null) { - return fetch(networkStore.indexV2) + return fetchStore(networkStore.indexV2) } networkStore.toExtensionStore(updatedIndexUrl) @@ -72,8 +76,8 @@ class ExtensionStoreService( } } - suspend fun getExtensions(store: ExtensionStore): Result> { - return try { + suspend fun getExtensions(store: ExtensionStore): Result> = withContext(Dispatchers.IO) { + try { val extensions = if (store.extensionListUrl != null) { val response = network.client.newCall(GET(store.extensionListUrl!!)).awaitSuccess() response.body.source().decompressIfGzipped().use { source -> diff --git a/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt b/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt index 225de5d10..af54e8606 100644 --- a/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt +++ b/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt @@ -9,6 +9,8 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.model.Release @@ -23,11 +25,13 @@ class ReleaseServiceImpl( ) : ReleaseService { override suspend fun latest(arguments: GetApplicationRelease.Arguments): Release? { - val release = with(json) { - networkService.client - .newCall(GET("https://api.github.com/repos/${arguments.repository}/releases/latest")) - .awaitSuccess() - .parseAs() + val release = withContext(Dispatchers.IO) { + with(json) { + networkService.client + .newCall(GET("https://api.github.com/repos/${arguments.repository}/releases/latest")) + .awaitSuccess() + .parseAs() + } } val downloadLink = getDownloadLink(release = release, isFoss = arguments.isFoss) ?: return null diff --git a/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt b/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt index 772c96fb3..69f62657c 100644 --- a/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt +++ b/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt @@ -5,8 +5,9 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mihon.domain.manga.model.toDomainManga -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.repository.SourcePagingSource @@ -54,7 +55,7 @@ abstract class BaseSourcePagingSource( return try { val source = source() - val mangasPage = withIOContext { + val mangasPage = withContext(Dispatchers.IO) { requestNextPage(source, page.toInt()) .takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException() diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt index e050cfc8b..2a90da0e0 100644 --- a/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/CreateCategoryWithName.kt @@ -2,7 +2,6 @@ package tachiyomi.domain.category.interactor import dev.zacsweers.metro.Inject import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.NewCategory import tachiyomi.domain.category.repository.CategoryRepository @@ -20,8 +19,8 @@ class CreateCategoryWithName( return sort.type.flag or sort.direction.flag } - suspend fun await(name: String): Result = withNonCancellableContext { - try { + suspend fun await(name: String): Result { + return try { categoryRepository.insert(NewCategory(name = name, flags = initialFlags)) Result.Success } catch (e: Exception) { diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteCategory.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteCategory.kt index 71050a5d6..25ccdf5ea 100644 --- a/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteCategory.kt +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/DeleteCategory.kt @@ -2,7 +2,6 @@ package tachiyomi.domain.category.interactor import dev.zacsweers.metro.Inject import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.repository.CategoryRepository @@ -16,12 +15,12 @@ class DeleteCategory( private val downloadPreferences: DownloadPreferences, ) { - suspend fun await(categoryId: Long) = withNonCancellableContext { + suspend fun await(categoryId: Long): Result { try { categoryRepository.delete(categoryId) } catch (e: Exception) { logcat(LogPriority.ERROR, e) - return@withNonCancellableContext Result.InternalError(e) + return Result.InternalError(e) } val orderedIds = categoryRepository.getAll() @@ -47,7 +46,7 @@ class DeleteCategory( preference.set(ids.minus(categoryIdString)) } - try { + return try { categoryRepository.updateAllOrders(orderedIds = orderedIds) Result.Success } catch (e: Exception) { diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/RenameCategory.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/RenameCategory.kt index 849939e56..0d3e8b553 100644 --- a/domain/src/main/java/tachiyomi/domain/category/interactor/RenameCategory.kt +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/RenameCategory.kt @@ -2,7 +2,6 @@ package tachiyomi.domain.category.interactor import dev.zacsweers.metro.Inject import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.repository.CategoryRepository @@ -12,8 +11,8 @@ class RenameCategory( private val categoryRepository: CategoryRepository, ) { - suspend fun await(categoryId: Long, name: String) = withNonCancellableContext { - try { + suspend fun await(categoryId: Long, name: String): Result { + return try { categoryRepository.updateName(categoryId = categoryId, name = name) Result.Success } catch (e: Exception) { diff --git a/domain/src/main/java/tachiyomi/domain/category/interactor/ReorderCategory.kt b/domain/src/main/java/tachiyomi/domain/category/interactor/ReorderCategory.kt index 106030d29..d7927007e 100644 --- a/domain/src/main/java/tachiyomi/domain/category/interactor/ReorderCategory.kt +++ b/domain/src/main/java/tachiyomi/domain/category/interactor/ReorderCategory.kt @@ -4,7 +4,6 @@ import dev.zacsweers.metro.Inject import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import logcat.LogPriority -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.repository.CategoryRepository @@ -15,26 +14,24 @@ class ReorderCategory( ) { private val mutex = Mutex() - suspend fun await(category: Category, newIndex: Int) = withNonCancellableContext { - mutex.withLock { - val categories = categoryRepository.getAll() - .filterNot(Category::isSystemCategory) - .toMutableList() + suspend fun await(category: Category, newIndex: Int) = mutex.withLock { + val categories = categoryRepository.getAll() + .filterNot(Category::isSystemCategory) + .toMutableList() - val currentIndex = categories.indexOfFirst { it.id == category.id } - if (currentIndex == -1) { - return@withNonCancellableContext Result.Unchanged - } + val currentIndex = categories.indexOfFirst { it.id == category.id } + if (currentIndex == -1) { + return@withLock Result.Unchanged + } - try { - categories.add(newIndex, categories.removeAt(currentIndex)) + try { + categories.add(newIndex, categories.removeAt(currentIndex)) - categoryRepository.updateAllOrders(orderedIds = categories.map { it.id }) - Result.Success - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - Result.InternalError(e) - } + categoryRepository.updateAllOrders(orderedIds = categories.map { it.id }) + Result.Success + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + Result.InternalError(e) } } diff --git a/domain/src/main/java/tachiyomi/domain/chapter/interactor/SetMangaDefaultChapterFlags.kt b/domain/src/main/java/tachiyomi/domain/chapter/interactor/SetMangaDefaultChapterFlags.kt index 5fa8144b6..5b25c9b9e 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/interactor/SetMangaDefaultChapterFlags.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/interactor/SetMangaDefaultChapterFlags.kt @@ -1,7 +1,6 @@ package tachiyomi.domain.chapter.interactor import dev.zacsweers.metro.Inject -import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.SetMangaChapterFlags @@ -15,15 +14,11 @@ class SetMangaDefaultChapterFlags( ) { suspend fun await(manga: Manga) { - withNonCancellableContext { - setDefaultFlags(listOf(manga.id)) - } + setDefaultFlags(listOf(manga.id)) } suspend fun awaitAll() { - withNonCancellableContext { - setDefaultFlags(getFavorites.await().map { it.id }) - } + setDefaultFlags(getFavorites.await().map { it.id }) } private suspend fun setDefaultFlags(mangaIds: List) { 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 a7b2d97e7..b8b31c30e 100644 --- a/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt +++ b/domain/src/main/java/tachiyomi/domain/storage/service/StorageManager.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import mihon.core.metro.AppCoroutineScope +import java.util.concurrent.ConcurrentHashMap @Inject @SingleIn(AppScope::class) @@ -26,7 +27,14 @@ class StorageManager( storagePreferences: StoragePreferences, ) { - private var baseDir: UniFile? = getBaseDir(storagePreferences.baseStorageDirectory.get()) + @Volatile + private var baseDirLazy: Lazy = lazy { getBaseDir(storagePreferences.baseStorageDirectory.get()) } + private val baseDir: UniFile? + get() = baseDirLazy.value + + // On SAF, createDirectory lists the whole base directory to find an existing child, so a resolved child + // is reused while it's under the current base directory and still exists; it can be deleted outside the app + private val childDirectories = ConcurrentHashMap>() private val _changes: Channel = Channel(Channel.UNLIMITED) val changes = _changes.receiveAsFlow() @@ -37,7 +45,7 @@ class StorageManager( .drop(1) .distinctUntilChanged() .onEach { uri -> - baseDir = getBaseDir(uri) + baseDirLazy = lazyOf(getBaseDir(uri)) baseDir?.let { parent -> parent.createDirectory(AUTOMATIC_BACKUPS_PATH) parent.createDirectory(LOCAL_SOURCE_PATH) @@ -56,15 +64,23 @@ class StorageManager( } fun getAutomaticBackupsDirectory(): UniFile? { - return baseDir?.createDirectory(AUTOMATIC_BACKUPS_PATH) + return getChildDirectory(AUTOMATIC_BACKUPS_PATH) } fun getDownloadsDirectory(): UniFile? { - return baseDir?.createDirectory(DOWNLOADS_PATH) + return getChildDirectory(DOWNLOADS_PATH) } fun getLocalSourceDirectory(): UniFile? { - return baseDir?.createDirectory(LOCAL_SOURCE_PATH) + return getChildDirectory(LOCAL_SOURCE_PATH) + } + + private fun getChildDirectory(name: String): UniFile? { + val parent = baseDir ?: return null + childDirectories[name] + ?.takeIf { (cachedParent, directory) -> cachedParent === parent && directory.exists() } + ?.let { (_, directory) -> return directory } + return parent.createDirectory(name)?.also { childDirectories[name] = parent to it } } } diff --git a/presentation-widget/src/main/java/tachiyomi/presentation/widget/BaseUpdatesGridGlanceWidget.kt b/presentation-widget/src/main/java/tachiyomi/presentation/widget/BaseUpdatesGridGlanceWidget.kt index d656329b2..ea527c2b0 100644 --- a/presentation-widget/src/main/java/tachiyomi/presentation/widget/BaseUpdatesGridGlanceWidget.kt +++ b/presentation-widget/src/main/java/tachiyomi/presentation/widget/BaseUpdatesGridGlanceWidget.kt @@ -22,7 +22,6 @@ import androidx.glance.layout.padding import androidx.glance.unit.ColorProvider import coil3.annotation.ExperimentalCoilApi import coil3.asDrawable -import coil3.executeBlocking import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest @@ -34,13 +33,14 @@ import dev.zacsweers.metro.HasMemberInjections import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.util.system.dpToPx +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import mihon.core.metro.metroGraph import mihon.presentation.widget.di.PresentationWidgetGraph -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.updates.interactor.GetUpdates import tachiyomi.domain.updates.model.UpdatesWithRelations @@ -122,7 +122,7 @@ abstract class BaseUpdatesGridGlanceWidget : GlanceAppWidget() { val widthPx = CoverWidth.value.toInt().dpToPx val heightPx = CoverHeight.value.toInt().dpToPx val roundPx = context.resources.getDimension(R.dimen.appwidget_inner_radius) - return withIOContext { + return withContext(Dispatchers.Default) { this@prepareData .distinctBy { it.mangaId } .take(rowCount * columnCount) @@ -149,7 +149,7 @@ abstract class BaseUpdatesGridGlanceWidget : GlanceAppWidget() { } } .build() - val bitmap = context.imageLoader.executeBlocking(request) + val bitmap = context.imageLoader.execute(request) .image ?.asDrawable(context.resources) ?.toBitmap() diff --git a/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt index 6307da865..b49bb04df 100644 --- a/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt @@ -15,9 +15,11 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SMangaUpdate import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn @@ -31,7 +33,6 @@ import nl.adaptivity.xmlutil.serialization.XML import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.nameWithoutExtension -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE @@ -86,7 +87,9 @@ class LocalSource( override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LatestFilters) - override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = withIOContext { + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = withContext( + Dispatchers.IO, + ) { val lastModifiedLimit = if (filters === LatestFilters) { System.currentTimeMillis() - LATEST_THRESHOLD } else { @@ -160,7 +163,7 @@ class LocalSource( } // Manga details related - private suspend fun getMangaDetails(manga: SManga): SManga = withIOContext { + private suspend fun getMangaDetails(manga: SManga): SManga = withContext(Dispatchers.IO) { coverManager.find(manga.url)?.let { manga.thumbnail_url = it.uri.toString() } @@ -224,7 +227,7 @@ class LocalSource( logcat(LogPriority.ERROR, e) { "Error setting manga details from local metadata for ${manga.title}" } } - return@withIOContext manga + return@withContext manga } private fun getComicInfoForChapter(chapter: UniFile, block: (InputStream) -> T): T? { @@ -287,7 +290,7 @@ class LocalSource( } // Chapters - private suspend fun getChapterList(manga: SManga): List = withIOContext { + private suspend fun getChapterList(manga: SManga): List = withContext(Dispatchers.IO) { val chapters = fileSystem.getFilesInMangaDirectory(manga.url) // Only keep supported formats .filterNot { it.name.orEmpty().startsWith('.') }