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.
This commit is contained in:
AntsyLich
2026-10-10 17:08:49 +00:00
committed by GitHub
parent f9c5fa9011
commit c46317f099
131 changed files with 1612 additions and 1274 deletions
@@ -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)
@@ -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()) {
@@ -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)
}
@@ -76,7 +76,7 @@ fun Manga.copyFrom(other: SManga): Manga {
}
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get<Context>().appGraph.coverCache): Boolean {
return coverCache.getCustomCoverFile(id).exists()
return coverCache.hasCustomCover(id)
}
/**
@@ -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<Context>().appGraph.extensionManager.getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
@@ -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,13 +30,12 @@ class AddTracks(
) {
// TODO: update all trackers based on common data
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withNonCancellableContext {
withIOContext {
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)
@@ -76,10 +75,8 @@ class AddTracks(
syncChapterProgressWithTrack.await(mangaId, track, tracker)
}
}
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withNonCancellableContext {
withIOContext {
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withContext(Dispatchers.IO) {
trackerManager.loggedInTrackers()
.filterIsInstance<EnhancedTracker>()
.filter { it.accept(source) }
@@ -105,4 +102,3 @@ class AddTracks(
}
}
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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<Result<ImageBitmap>> {
val context = LocalContext.current
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext {
withContext(Dispatchers.IO) {
value = try {
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!!
val appResources = context.packageManager.getResourcesForApplication(appInfo)
@@ -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<LibraryDisplayMode>,
@@ -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)
@@ -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,19 +169,20 @@ fun MangaCoverDialog(
},
) { contentPadding ->
if (useNewRenderer) {
val state = ImageViewerState()
ImageRequest.Builder(view.context)
val state = remember(manga) { ImageViewerState() }
LaunchedEffect(manga) {
val request = ImageRequest.Builder(view.context)
.data(manga)
.size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true)
.target { result ->
val res = (result as ImageDecoder.DecodeResultImage)
.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 {
runBlocking(Dispatchers.Default) {
withContext(Dispatchers.Default) {
ImagePage.ImageSingle(
Image(
res.image,
@@ -213,8 +216,6 @@ fun MangaCoverDialog(
invalidate()
}
}
.build()
.let(view.context.imageLoader::enqueue)
ImageViewer(state = state)
return@Scaffold
@@ -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()
scope.launch(Dispatchers.IO) {
context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
context.toast(MR.strings.webview_data_deleted)
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,15 +292,14 @@ 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 {
@@ -305,7 +307,6 @@ object SettingsAdvancedScreen : SearchableSettings {
}
context.toast(message)
}
}
},
),
Preference.PreferenceItem.SwitchPreference(
@@ -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,6 +233,7 @@ object SettingsDataScreen : SearchableSettings {
modifier = Modifier.fillMaxHeight(),
checked = false,
onCheckedChange = {
scope.launch {
if (!BackupRestoreWorker.isRunning(context.workManager)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning)
@@ -239,6 +242,7 @@ object SettingsDataScreen : SearchableSettings {
} 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 {
val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
cacheReadableSizeSema++
}
} catch (e: Throwable) {
} 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)
}
}
},
@@ -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
}
}
@@ -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)
}
@@ -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<ViewModel>())
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<ClearDatabaseViewModel.State>(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
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 ->
@@ -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) }
}
@@ -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<CreateBackupViewModel>()
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
val chooseBackupDir = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/*"),
@@ -75,6 +78,7 @@ class CreateBackupScreen : Screen() {
actionLabel = stringResource(MR.strings.action_create),
actionEnabled = state.options.canCreate(),
onClickAction = {
scope.launch {
if (!BackupCreateWorker.isManualJobRunning(context)) {
try {
chooseBackupDir.launch(BackupCreator.getFilename())
@@ -84,6 +88,7 @@ class CreateBackupScreen : Screen() {
} else {
context.toast(MR.strings.backup_in_progress)
}
}
},
) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
@@ -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())
}
}
@@ -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<File>()) {
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(
@@ -54,7 +54,7 @@ fun UpdateScreen(
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean,
onUpdateLibrary: suspend () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, 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)
+15 -4
View File
@@ -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<AppGraph> {
@@ -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<CoverCache>
@Inject private lateinit var networkHelper: NetworkHelper
@Inject private lateinit var networkHelper: Lazy<NetworkHelper>
@Inject private lateinit var sourceManager: SourceManager
@Inject private lateinit var sourceManager: Lazy<SourceManager>
@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))
@@ -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)
}
@@ -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 {
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)
}
@@ -36,17 +36,20 @@ class ChapterCache(
) {
/** Cache class used for cache management. */
private val diskCache = DiskLruCache.open(
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.
@@ -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<String>()
// Deleted while the directory was being listed, so the listing may still contain them
private val deletedWhileIndexing = mutableSetOf<String>()
@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 {
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
private val coverCache: CoverCache,
) : Keyer<MangaCover> {
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}"
@@ -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<String>? = null
var oldChapterDirs: Set<String>? = 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<Long, SourceDirectory> = mapOf(),
)
@@ -483,6 +478,7 @@ private class RootDirectory(
private class SourceDirectory(
@Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?,
@Volatile
var mangaDirs: Map<String, MangaDirectory> = mapOf(),
)
@@ -493,7 +489,9 @@ private class SourceDirectory(
private class MangaDirectory(
@Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?,
var chapterDirs: MutableSet<String> = mutableSetOf(),
// Replaced rather than changed in place, so the lock-free lookups always read a consistent set
@Volatile
var chapterDirs: Set<String> = emptySet(),
)
private object UniFileAsStringSerializer : KSerializer<UniFile?> {
@@ -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<Download>) {
downloader.updateQueue(downloads)
suspend fun reorderQueue(downloads: List<Download>) {
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<Download>) {
suspend fun addDownloadsToStartOfQueue(downloads: List<Download>) {
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<Page> {
suspend fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> {
val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source)
val files = chapterDir?.listFiles().orEmpty()
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<Download>) {
removeFromDownloadQueue(downloads.map { it.chapter })
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
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<Chapter>, manga: Manga, source: Source) {
scope.launchIO {
suspend fun deleteChapters(chapters: List<Chapter>, 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<Chapter>) {
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,24 +388,27 @@ 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) {
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
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
.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
if (oldDownload.name == newName) return@withContext
if (oldDownload.renameTo(newName)) {
cache.removeChapter(oldChapter, manga)
@@ -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.
@@ -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<UniFile> {
internal suspend fun getMangaDir(mangaTitle: String, source: Source): Result<UniFile> =
withContext(Dispatchers.IO) { createMangaDir(mangaTitle, source) }
private fun createMangaDir(mangaTitle: String, source: Source): Result<UniFile> {
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<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter ->
suspend fun findChapterDirs(
chapters: List<Chapter>,
manga: Manga,
source: Source,
): Pair<UniFile?, List<UniFile>> = 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()
@@ -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.
@@ -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()
@@ -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<List<Download>>(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<Download, Job>,
) = 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<Unit>()
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<Unit>,
) = 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,9 +356,11 @@ class Downloader(
val source = sourceManager.get(manga.source) as? HttpSource ?: return false
val wasEmpty = queueState.value.isEmpty()
val chaptersToQueue = chapters.asSequence()
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.
@@ -331,6 +368,7 @@ class Downloader(
// 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<Download>) {
_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<Chapter>) {
private fun removeFromQueue(chapters: List<Chapter>) {
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<Download>) {
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<Download>) -> List<Download>) = 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<Chapter>) = 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()
}
}
@@ -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<Pair<Manga, Array<Chapter>>>) {
suspend fun showUpdateNotifications(updates: List<Pair<Manga, Array<Chapter>>>) {
// Parent group notification
context.notify(
Notifications.ID_NEW_CHAPTERS,
@@ -216,7 +212,6 @@ class LibraryUpdateNotifier(
// Per-manga notification
if (!securityPreferences.hideNotificationContent.get()) {
scope.launchUI {
context.notify(
updates.map { (manga, chapters) ->
NotificationManagerCompat.NotificationWithIdAndTag(
@@ -227,7 +222,6 @@ class LibraryUpdateNotifier(
)
}
}
}
private suspend fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification {
val icon = getMangaIcon(manga)
@@ -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)
}
// 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)
@@ -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
@@ -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,8 +153,10 @@ class NotificationReceiver : BroadcastReceiver() {
* @param context context of application
*/
private fun cancelLibraryUpdate(context: Context) {
launchAsync {
LibraryUpdateWorker.stop(context)
}
}
/**
* Method called when user wants to mark manga chapters as read
@@ -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"
/**
@@ -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)
}
}
@@ -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()
@@ -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<BGMOAuth>()
}
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<BGMOAuth>() }.also(::newAuth)
}
fun newAuth(oauth: BGMOAuth?) {
this.oauth = oauth
bangumi.saveToken(oauth)
@@ -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<TrackSearch> {
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()
@@ -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<HKAuthTokenInfo>()
}
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<HKAuthTokenInfo>()
}
HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created).also(::setAuth)
}
fun setAuth(oauth: HKOAuth?) {
this.oauth = oauth
hikka.saveOAuth(oauth)
@@ -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)
}
@@ -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)
@@ -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))
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) {
currAuth = with(json) {
response.parseAs<KitsuOAuth>()
}
newAuth(currAuth)
with(json) { response.parseAs<KitsuOAuth>() }.also(::newAuth)
} else {
response.close()
latest
}
}
}
@@ -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)
}
@@ -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<TrackSearch> {
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)
@@ -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))
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) {
currentAuth = with(json) {
response.parseAs<MangaBakaOAuth>()
}
setAuth(currentAuth)
with(json) { response.parseAs<MangaBakaOAuth>() }.also(::setAuth)
} else {
response.close()
latest
}
}
}
@@ -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,11 +45,13 @@ class MangaUpdatesApi(
}
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
val listItem = with(json) {
val listItem = withContext(Dispatchers.IO) {
with(json) {
authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
.awaitSuccess()
.parseAs<MUListItem>()
}
}
val rating = getSeriesRating(track)
@@ -98,7 +101,7 @@ class MangaUpdatesApi(
body = body.toString().toRequestBody(CONTENT_TYPE),
),
)
.awaitSuccess()
.awaitSuccess().close()
updateSeriesRating(track)
}
@@ -113,16 +116,18 @@ class MangaUpdatesApi(
body = body.toString().toRequestBody(CONTENT_TYPE),
),
)
.awaitSuccess()
.awaitSuccess().close()
}
private suspend fun getSeriesRating(track: Track): MURating? {
return try {
withContext(Dispatchers.IO) {
with(json) {
authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
.awaitSuccess()
.parseAs<MURating>()
}
}
} 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,7 +166,8 @@ class MangaUpdatesApi(
)
}
return with(json) {
return withContext(Dispatchers.IO) {
with(json) {
client.newCall(
POST(
url = "$BASE_URL/v1/series/search",
@@ -174,9 +180,10 @@ class MangaUpdatesApi(
.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,7 +204,8 @@ class MangaUpdatesApi(
put("username", username)
put("password", password)
}
return with(json) {
return withContext(Dispatchers.IO) {
with(json) {
client.newCall(
PUT(
url = "$BASE_URL/v1/account/login",
@@ -209,14 +217,17 @@ class MangaUpdatesApi(
.context
}
}
}
suspend fun getCurrentUser(): MUCurrentUser {
return with(json) {
return withContext(Dispatchers.IO) {
with(json) {
authClient.newCall(GET("$BASE_URL/v1/account/profile"))
.awaitSuccess()
.parseAs<MUCurrentUser>()
}
}
}
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"
@@ -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<TrackSearch> {
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<TrackSearch> {
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())
@@ -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()
@@ -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!!))
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) {
currAuth = with(json) {
response.parseAs<SMOAuth>()
}
newAuth(currAuth)
with(json) { response.parseAs<SMOAuth>() }.also(::newAuth)
} else {
response.close()
latest
}
}
}
// 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()
@@ -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)
}
@@ -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,
)
}
@@ -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,
@@ -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<Unit>()
private val iconMap = mutableMapOf<String, Drawable>()
// 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<String, Drawable>()
@Volatile
private var stores = emptyList<ExtensionStore>()
@@ -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<Extension.Available> {
return withIOContext { extensionStoreRepository.fetchExtensions() }
return extensionStoreRepository.fetchExtensions()
}
private fun setAvailableExtensions(extensions: List<Extension.Available>) {
@@ -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,7 +390,8 @@ class ExtensionManager(
private fun refreshStatuses() {
val available = availableExtensionListFlow.value
if (available.isNotEmpty()) {
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
loadedExtensionMapFlow.update { current ->
current.mapValues { (_, extension) ->
val listing = extension.findListing(available)
extension.copy(
hasUpdate = extension.findUpdate(available) != null,
@@ -393,13 +399,16 @@ class ExtensionManager(
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
)
}
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) ->
}
notLoadedExtensionMapFlow.update { current ->
current.mapValues { (_, extension) ->
extension.copy(
hasUpdate = extension.findUpdate(available) != null,
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
)
}
}
}
updatePendingUpdatesCount()
}
@@ -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<Entry, Int>? = null
// Always ready
@@ -76,6 +84,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
override fun processEntry(entry: Entry) {
super.processEntry(entry)
activeSession = null
scope.launch {
try {
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -107,9 +116,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
} catch (e: Exception) {
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId)
// Already abandoned when the entry was cancelled mid-copy
runCatching { packageInstaller.abandonSession(sessionId) }
}
if (getActiveEntry() == entry) continueQueue(InstallStep.Error)
}
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()
}
@@ -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,6 +118,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) {
super.processEntry(entry)
scope.launch {
try {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
shellInterface?.install(it, statusIntent.intentSender)
@@ -127,6 +129,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
continueQueue(InstallStep.Error)
}
}
}
// Don't cancel if entry is already started installing
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
@@ -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<Event>(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 {
@@ -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<String, Job>()
private val activeSteps = mutableMapOf<Long, MutableStateFlow<InstallStep>>()
private val activeJobs = ConcurrentHashMap<String, Job>()
private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
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,16 +73,19 @@ class ExtensionInstaller(
try {
step.value = InstallStep.Downloading
val request = Request.Builder().url(extension.apkUrl).build()
val response = httpClient.newCall(request).execute()
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)
}
}
}
}
// The store's index names the apk, but nothing ties what's served there to the store's key
val packageInfo = ExtensionLoader.getArchivePackageInfo(context, tmpFile)
@@ -87,17 +95,15 @@ 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 {
if (e is CancellationException) throw e
logcat(LogPriority.INFO, e)
step.value = InstallStep.Error
}
}
}
activeJobs[extension.pkgName] = job
@@ -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 {
@@ -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) {
@@ -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<State> = 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<Extension.Installed>()
@@ -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()
@@ -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)
@@ -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) {
@@ -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<State> { throwable -> emit(State.Error(throwable = throwable)) }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
fun toggleSource(source: Source) {
@@ -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))
@@ -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<Long>) {
viewModelScope.launchIO {
viewModelScope.launch(Dispatchers.IO) {
setMangaCategories.await(
mangaId = manga.id,
categoryIds = categoryIds.toList(),
@@ -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)) }
}
}
@@ -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<ViewModel>())
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 -> {}
}
@@ -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<ResolvableSource>()
.firstOrNull { it.getUriType(query) != UriType.Unknown }
@@ -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)
}
@@ -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<Download>) {
downloadManager.reorderQueue(downloads)
viewModelScope.launch { downloadManager.reorderQueue(downloads) }
}
fun cancel(downloads: List<Download>) {
downloadManager.cancelQueuedDownloads(downloads)
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
}
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
@@ -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<ViewModel>())
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<Long>) {
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)) }
}
}
@@ -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)
}
}
@@ -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(
@@ -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<ViewModel>())
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<State> = 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<Manga>, 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<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
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
@@ -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()
}
}
@@ -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)
@@ -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) {
@@ -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)
@@ -271,19 +273,26 @@ class MangaViewModel(
dialog = dialog,
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<State.Success>().first()
val manga = loadedState.manga
@@ -324,7 +333,6 @@ class MangaViewModel(
fetchChapters: Boolean,
) {
try {
withUIContext {
val update = updateMangaFromRemote(
source = sourceManager.getOrStub(manga.source),
manga = manga,
@@ -337,7 +345,6 @@ class MangaViewModel(
if (manualFetch) {
downloadNewChapters(update.newChapters)
}
}
} catch (_: CancellationException) {
// ignore
} catch (e: Exception) {
@@ -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<Long>) {
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)
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)
toggleAllSelection(false)
}
}.join()
if (startNowId == null) toggleAllSelection(false)
if (!isFavorited && !hasPromptedToAddBefore) {
hasPromptedToAddBefore = true
@@ -710,9 +720,11 @@ class MangaViewModel(
private fun cancelDownload(chapterId: Long) {
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
viewModelScope.launch {
downloadManager.cancelQueuedDownloads(listOf(activeDownload))
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
}
}
fun markPreviousChapterRead(pointer: Chapter) {
val manga = successState?.manga ?: return
@@ -730,14 +742,17 @@ class MangaViewModel(
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
toggleAllSelection(false)
if (chapters.isEmpty()) return
viewModelScope.launchIO {
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
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<Chapter>, 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<Chapter>) {
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<Chapter>) {
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 {
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<String>) {
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) }
}
}
}
@@ -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)
}
}
@@ -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) }
}
}
}
@@ -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,
@@ -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
@@ -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) }
}
/**
@@ -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<Chapter>? = null
private suspend fun getUnfilteredChapterList(): List<Chapter> {
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<ReaderChapter>
private suspend fun loadChapterList(): List<ReaderChapter> {
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,8 +619,12 @@ 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) {
// 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)
}
@@ -622,6 +636,7 @@ class ReaderViewModel(
)
}
}
}
private suspend fun updateChapterProgressOnComplete(readerChapter: ReaderChapter) {
updateTrackChapterRead(readerChapter)
@@ -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,8 +902,9 @@ class ReaderViewModel(
""
}
// Copy file in background.
viewModelScope.launchNonCancellable {
// 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(
@@ -888,14 +913,14 @@ class ReaderViewModel(
location = Location.Pictures.create(relativePath),
),
)
withUIContext {
notifier.onComplete(uri)
eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri)))
}
withContext(Dispatchers.Main) { notifier.onComplete(uri) }
SaveImageResult.Success(uri)
} catch (e: Throwable) {
notifier.onError(e.message)
eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
SaveImageResult.Error(e)
}
}.await()
eventChannel.send(Event.SavedImage(result))
}
}
@@ -915,8 +940,8 @@ class ReaderViewModel(
val filename = generateFilename(manga, page)
viewModelScope.launch(Dispatchers.IO) {
try {
viewModelScope.launchNonCancellable {
destDir.deleteRecursively()
val uri = imageSaver.save(
image = Image.Page(
@@ -926,11 +951,12 @@ class ReaderViewModel(
),
)
eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page))
}
} catch (e: Throwable) {
} catch (e: Exception) {
if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e)
}
}
}
/**
* Sets the image of the selected page as cover and notifies the UI of the result.
@@ -941,8 +967,9 @@ class ReaderViewModel(
val manga = manga ?: return
val stream = page.stream ?: return
viewModelScope.launchNonCancellable {
val result = try {
viewModelScope.launch {
val result = appScope.async {
try {
manga.editCover(coverManager, stream(), updateManga, coverCache)
if (manga.isLocal() || manga.favorite) {
SetAsCoverResult.Success
@@ -952,6 +979,7 @@ class ReaderViewModel(
} 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,20 +1015,19 @@ 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<Chapter>) {
private suspend fun enqueueDeleteChapters(chapters: List<Chapter>) {
val manga = manga ?: return
viewModelScope.launchNonCancellable {
downloadManager.enqueueChaptersToDelete(chapters, manga)
}
}
/**
* Deletes all the pending chapters. This operation will run in a background thread and errors
* 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()
}
}
@@ -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,
@@ -56,7 +56,7 @@ internal class DownloadPageLoader(
return loader.getPages()
}
private fun getPagesFromDirectory(): List<ReaderPage> {
private suspend fun getPagesFromDirectory(): List<ReaderPage> {
val pages = downloadManager.buildPageList(source, manga, chapter.chapter)
return pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl) {
@@ -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<Int, Int>()
private var inFlight: Pair<Int, Job>? = null
init {
scope.launchIO {
scope.launch(Dispatchers.IO) {
flow {
while (true) {
emit(runInterruptible { queue.take() })
@@ -51,11 +59,18 @@ internal class HttpPageLoader(
}
.filter { it.page.status == Page.State.Queue }
.collect {
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<PriorityPage>()
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<Nothing> { 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<PriorityPage> {
private fun nextPages(currentPage: ReaderPage, amount: Int): List<ReaderPage> {
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))
}
/**
@@ -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)?,
)
}
@@ -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)
}
}
@@ -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) {
@@ -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)
}
}
@@ -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 }
@@ -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<ViewModel>())
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<State> = combine(
@@ -199,6 +200,7 @@ class UpdatesViewModel(
dialog = dialog,
)
}
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
private fun List<UpdatesItem>.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))
}
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<UpdatesItem>, 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<UpdatesItem>, 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<UpdatesItem>) {
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<UpdatesItem>) {
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)
@@ -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,13 +69,15 @@ class WebViewActivity : BaseActivity() {
// Null until the source it belongs to has been resolved
val headers by produceState<Map<String, String>?>(initialValue = null) {
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
value = try {
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()
}
}
}
if (headers == null) {
LoadingScreen()

Some files were not shown because too many files have changed in this diff Show More