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 dev.zacsweers.metro.Inject
import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.download.interactor.DeleteDownload
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate 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 { val chaptersToUpdate = chapters.filter {
when (read) { when (read) {
true -> !it.read true -> !it.read
@@ -35,7 +34,7 @@ class SetReadStatus(
} }
} }
if (chaptersToUpdate.isEmpty()) { if (chaptersToUpdate.isEmpty()) {
return@withNonCancellableContext Result.NoChapters return Result.NoChapters
} }
try { try {
@@ -44,7 +43,7 @@ class SetReadStatus(
) )
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
return@withNonCancellableContext Result.InternalError(e) return Result.InternalError(e)
} }
if (read && downloadPreferences.removeAfterMarkedAsRead.get()) { 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( await(
read = read, read = read,
chapters = chapterRepository chapters = chapterRepository
.getChapterByMangaId(mangaId) .getChapterByMangaId(mangaId)
.toTypedArray(), .toTypedArray(),
) )
}
suspend fun await(manga: Manga, read: Boolean) = suspend fun await(manga: Manga, read: Boolean) =
await(manga.id, read) await(manga.id, read)
@@ -151,7 +151,7 @@ class SyncChaptersWithSource(
// The source no longer lists these, so their queued downloads could only fail // The source no longer lists these, so their queued downloads could only fail
removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) } removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) }
.takeIf { it.isNotEmpty() } .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. // Return if there's nothing to add, delete, or update to avoid unnecessary db transactions.
if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) { if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) {
@@ -2,7 +2,6 @@ package eu.kanade.domain.download.interactor
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@@ -13,7 +12,7 @@ class DeleteDownload(
private val downloadManager: DownloadManager, 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 -> sourceManager.get(manga.source)?.let { source ->
downloadManager.deleteChapters(chapters.toList(), manga, 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 { 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.app.di.appGraph import mihon.app.di.appGraph
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get 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) Injekt.get<Context>().appGraph.extensionManager.getAppIconForSource(id)
?.toBitmap() ?.toBitmap()
?.asImageBitmap() ?.asImageBitmap()
@@ -9,10 +9,10 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import logcat.LogPriority 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.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
@@ -30,13 +30,12 @@ class AddTracks(
) { ) {
// TODO: update all trackers based on common data // TODO: update all trackers based on common data
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withNonCancellableContext { suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withContext(Dispatchers.IO) {
withIOContext {
val allChapters = getChaptersByMangaId.await(mangaId) val allChapters = getChaptersByMangaId.await(mangaId)
val hasReadChapters = allChapters.any { it.read } val hasReadChapters = allChapters.any { it.read }
tracker.bind(item, hasReadChapters) tracker.bind(item, hasReadChapters)
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext var track = item.toDomainTrack(idRequired = false) ?: return@withContext
upsertTrack.await(track) upsertTrack.await(track)
@@ -76,10 +75,8 @@ class AddTracks(
syncChapterProgressWithTrack.await(mangaId, track, tracker) syncChapterProgressWithTrack.await(mangaId, track, tracker)
} }
}
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withNonCancellableContext { suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withContext(Dispatchers.IO) {
withIOContext {
trackerManager.loggedInTrackers() trackerManager.loggedInTrackers()
.filterIsInstance<EnhancedTracker>() .filterIsInstance<EnhancedTracker>()
.filter { it.accept(source) } .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.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@@ -30,7 +31,7 @@ class RefreshTracks(
.map { it to trackerManager.get(it.trackerId) } .map { it to trackerManager.get(it.trackerId) }
.filter { (_, service) -> service?.isLoggedIn == true } .filter { (_, service) -> service?.isLoggedIn == true }
.map { (track, service) -> .map { (track, service) ->
async { async(Dispatchers.IO) {
return@async try { return@async try {
val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!! val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!!
upsertTrack.await(updatedTrack) upsertTrack.await(updatedTrack)
@@ -9,8 +9,8 @@ import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.UpsertTrack import tachiyomi.domain.track.interactor.UpsertTrack
@@ -24,9 +24,9 @@ class TrackChapter(
) { ) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) { suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) {
withNonCancellableContext { coroutineScope {
val tracks = getTracks.await(mangaId) val tracks = getTracks.await(mangaId)
if (tracks.isEmpty()) return@withNonCancellableContext if (tracks.isEmpty()) return@coroutineScope
tracks.mapNotNull { track -> tracks.mapNotNull { track ->
val service = trackerManager.get(track.trackerId) 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.interactor.TrackChapter
import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracks
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -38,7 +39,7 @@ class DelayedTrackingUpdateWorker(private val context: Context, workerParams: Wo
return Result.failure() return Result.failure()
} }
withIOContext { withContext(Dispatchers.IO) {
delayedTrackingStore.getItems() delayedTrackingStore.getItems()
.mapNotNull { .mapNotNull {
val track = getTracks.awaitOne(it.trackId) val track = getTracks.awaitOne(it.trackId)
@@ -15,7 +15,7 @@ class DelayedTrackingStore(context: Context) {
/** /**
* Preference file where queued tracking updates are stored. * 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) { fun add(trackId: Long, lastChapterRead: Double) {
val previousLastChapterRead = preferences.getFloat(trackId.toString(), 0f) 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.R
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.roundedfilled.Dangerous import mihon.icons.materialsymbols.roundedfilled.Dangerous
import mihon.icons.materialsymbols.roundedfilled.Warning import mihon.icons.materialsymbols.roundedfilled.Warning
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
@@ -125,7 +126,7 @@ fun ExtensionIcon(
private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State<Result<ImageBitmap>> { private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State<Result<ImageBitmap>> {
val context = LocalContext.current val context = LocalContext.current
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) { return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext { withContext(Dispatchers.IO) {
value = try { value = try {
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!!
val appResources = context.packageManager.getResourcesForApplication(appInfo) val appResources = context.packageManager.getResourcesForApplication(appInfo)
@@ -39,7 +39,7 @@ fun LibraryContent(
onContinueReadingClicked: ((LibraryManga) -> Unit)?, onContinueReadingClicked: ((LibraryManga) -> Unit)?,
onToggleSelection: (Category, LibraryManga) -> Unit, onToggleSelection: (Category, LibraryManga) -> Unit,
onToggleRangeSelection: (Category, LibraryManga) -> Unit, onToggleRangeSelection: (Category, LibraryManga) -> Unit,
onRefresh: () -> Boolean, onRefresh: suspend () -> Boolean,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
getItemCountForCategory: (Category) -> Int?, getItemCountForCategory: (Category) -> Int?,
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>, getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
@@ -80,9 +80,8 @@ fun LibraryContent(
refreshing = isRefreshing, refreshing = isRefreshing,
enabled = selection.isEmpty(), enabled = selection.isEmpty(),
onRefresh = { onRefresh = {
val started = onRefresh()
if (!started) return@PullRefresh
scope.launch { scope.launch {
if (!onRefresh()) return@launch
// Fake refresh status but hide it after a second as it's a long running task // Fake refresh status but hide it after a second as it's a long running task
isRefreshing = true isRefreshing = true
delay(1.seconds) delay(1.seconds)
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -44,6 +45,7 @@ import coil3.asDrawable
import coil3.imageLoader import coil3.imageLoader
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Size import coil3.size.Size
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions 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.data.coil.newDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
@@ -167,19 +169,20 @@ fun MangaCoverDialog(
}, },
) { contentPadding -> ) { contentPadding ->
if (useNewRenderer) { if (useNewRenderer) {
val state = ImageViewerState() val state = remember(manga) { ImageViewerState() }
LaunchedEffect(manga) {
ImageRequest.Builder(view.context) val request = ImageRequest.Builder(view.context)
.data(manga) .data(manga)
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true) .newDecoder(true)
.target { result -> .build()
val res = (result as ImageDecoder.DecodeResultImage) 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 // Held, then freed, around the upload: its buffer alone doesn't keep
// the frame's native pixels alive. // the frame's native pixels alive.
val page = res.frame.use { val page = res.frame.use {
runBlocking(Dispatchers.Default) { withContext(Dispatchers.Default) {
ImagePage.ImageSingle( ImagePage.ImageSingle(
Image( Image(
res.image, res.image,
@@ -213,8 +216,6 @@ fun MangaCoverDialog(
invalidate() invalidate()
} }
} }
.build()
.let(view.context.imageLoader::enqueue)
ImageViewer(state = state) ImageViewer(state = state)
return@Scaffold 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.setDefaultSettings
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import okhttp3.Headers 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.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -193,6 +193,7 @@ object SettingsAdvancedScreen : SearchableSettings {
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val networkHelper = remember { context.appGraph.networkHelper } val networkHelper = remember { context.appGraph.networkHelper }
val scope = rememberCoroutineScope()
val userAgentPref = networkPreferences.defaultUserAgent val userAgentPref = networkPreferences.defaultUserAgent
val userAgent by userAgentPref.collectAsState() val userAgent by userAgentPref.collectAsState()
@@ -219,8 +220,10 @@ object SettingsAdvancedScreen : SearchableSettings {
clearSslPreferences() clearSslPreferences()
} }
WebStorage.getInstance().deleteAllData() WebStorage.getInstance().deleteAllData()
scope.launch(Dispatchers.IO) {
context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() } 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) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
context.toast(MR.strings.cache_delete_error) context.toast(MR.strings.cache_delete_error)
@@ -289,15 +292,14 @@ object SettingsAdvancedScreen : SearchableSettings {
preferenceItems = listOf( preferenceItems = listOf(
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_refresh_library_covers), title = stringResource(MR.strings.pref_refresh_library_covers),
onClick = { MetadataUpdateWorker.startNow(context.workManager) }, onClick = { scope.launch { MetadataUpdateWorker.startNow(context.workManager) } },
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_reset_viewer_flags), title = stringResource(MR.strings.pref_reset_viewer_flags),
subtitle = stringResource(MR.strings.pref_reset_viewer_flags_summary), subtitle = stringResource(MR.strings.pref_reset_viewer_flags_summary),
onClick = { onClick = {
scope.launchNonCancellable { scope.launch {
val success = context.appGraph.resetViewerFlags.await() val success = context.appGraph.resetViewerFlags.await()
withUIContext {
val message = if (success) { val message = if (success) {
MR.strings.pref_reset_viewer_flags_success MR.strings.pref_reset_viewer_flags_success
} else { } else {
@@ -305,7 +307,6 @@ object SettingsAdvancedScreen : SearchableSettings {
} }
context.toast(message) context.toast(message)
} }
}
}, },
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
@@ -28,6 +28,7 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Help import mihon.icons.materialsymbols.automirroredrounded.Help
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath 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.core.common.util.system.logcat
import tachiyomi.domain.backup.service.BackupPreferences import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -181,6 +182,7 @@ object SettingsDataScreen : SearchableSettings {
private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup { private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp.collectAsState() val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp.collectAsState()
@@ -231,6 +233,7 @@ object SettingsDataScreen : SearchableSettings {
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
checked = false, checked = false,
onCheckedChange = { onCheckedChange = {
scope.launch {
if (!BackupRestoreWorker.isRunning(context.workManager)) { if (!BackupRestoreWorker.isRunning(context.workManager)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(MR.strings.restore_miui_warning) context.toast(MR.strings.restore_miui_warning)
@@ -239,6 +242,7 @@ object SettingsDataScreen : SearchableSettings {
} else { } else {
context.toast(MR.strings.restore_in_progress) context.toast(MR.strings.restore_in_progress)
} }
}
}, },
shape = SegmentedButtonDefaults.itemShape(1, 2), shape = SegmentedButtonDefaults.itemShape(1, 2),
) { ) {
@@ -282,7 +286,9 @@ object SettingsDataScreen : SearchableSettings {
val chapterCache = remember { context.appGraph.chapterCache } val chapterCache = remember { context.appGraph.chapterCache }
var cacheReadableSizeSema by remember { mutableIntStateOf(0) } 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( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_storage_usage), title = stringResource(MR.strings.pref_storage_usage),
@@ -303,16 +309,15 @@ object SettingsDataScreen : SearchableSettings {
title = stringResource(MR.strings.pref_clear_chapter_cache), title = stringResource(MR.strings.pref_clear_chapter_cache),
subtitle = stringResource(MR.strings.used_cache, cacheReadableSize), subtitle = stringResource(MR.strings.used_cache, cacheReadableSize),
onClick = { onClick = {
scope.launchNonCancellable { scope.launch {
try { try {
val deletedFiles = chapterCache.clear() val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
withUIContext {
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles)) context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
cacheReadableSizeSema++ cacheReadableSizeSema++
} } catch (e: Exception) {
} catch (e: Throwable) { if (e is CancellationException) throw e
logcat(LogPriority.ERROR, 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.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast 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.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Help import mihon.icons.materialsymbols.automirroredrounded.Help
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.roundedfilled.Visibility import mihon.icons.materialsymbols.roundedfilled.Visibility
import mihon.icons.materialsymbols.roundedfilled.VisibilityOff 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.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@@ -276,7 +277,7 @@ object SettingsTrackingScreen : SearchableSettings {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !processing && username.text.isNotBlank() && password.text.isNotBlank(), enabled = !processing && username.text.isNotBlank() && password.text.isNotBlank(),
onClick = { onClick = {
scope.launchIO { scope.launch(Dispatchers.IO) {
processing = true processing = true
val result = checkLogin( val result = checkLogin(
context = context, context = context,
@@ -305,11 +306,11 @@ object SettingsTrackingScreen : SearchableSettings {
): Boolean { ): Boolean {
return try { return try {
tracker.login(username, password) tracker.login(username, password)
withUIContext { context.toast(MR.strings.login_success) } withContext(Dispatchers.Main) { context.toast(MR.strings.login_success) }
true true
} catch (e: Throwable) { } catch (e: Throwable) {
tracker.logout() tracker.logout()
withUIContext { context.toast(e.message.toString()) } withContext(Dispatchers.Main) { context.toast(e.message.toString()) }
false 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.isNightlyBuildType
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.updaterEnabled import eu.kanade.tachiyomi.util.system.updaterEnabled
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority import logcat.LogPriority
@@ -50,8 +52,6 @@ import mihon.icons.simpleicons.Reddit
import mihon.icons.simpleicons.SimpleIcons import mihon.icons.simpleicons.SimpleIcons
import mihon.icons.simpleicons.X import mihon.icons.simpleicons.X
import tachiyomi.core.common.Constants 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.core.common.util.system.logcat
import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -218,9 +218,9 @@ object AboutScreen : Screen() {
onFinish: () -> Unit, onFinish: () -> Unit,
) { ) {
val updateChecker = context.appGraph.updateChecker val updateChecker = context.appGraph.updateChecker
withUIContext { withContext(Dispatchers.Main) {
try { try {
when (val result = withIOContext { updateChecker.checkForUpdate(forceCheck = true) }) { when (val result = updateChecker.checkForUpdate(forceCheck = true)) {
is GetApplicationRelease.Result.NewUpdate -> { is GetApplicationRelease.Result.NewUpdate -> {
onAvailableUpdate(result) onAvailableUpdate(result)
} }
@@ -42,16 +42,17 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mihon.core.metro.AppCoroutineScope
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.FlipToBack import mihon.icons.materialsymbols.rounded.FlipToBack
import mihon.icons.materialsymbols.rounded.SelectAll 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.history.repository.HistoryRepository
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
@@ -117,7 +118,7 @@ class ClearDatabaseScreen : Screen() {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
scope.launchUI { scope.launch(Dispatchers.Main) {
viewModel.removeMangaBySourceId(keepReadManga) viewModel.removeMangaBySourceId(keepReadManga)
viewModel.clearSelection() viewModel.clearSelection()
viewModel.hideConfirmation() viewModel.hideConfirmation()
@@ -229,6 +230,7 @@ class ClearDatabaseScreen : Screen() {
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>()) @ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class ClearDatabaseViewModel( class ClearDatabaseViewModel(
@AppCoroutineScope private val appScope: CoroutineScope,
private val mangaRepository: MangaRepository, private val mangaRepository: MangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga, private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga,
@@ -238,7 +240,7 @@ class ClearDatabaseViewModel(
field = MutableStateFlow<ClearDatabaseViewModel.State>(State.Loading) field = MutableStateFlow<ClearDatabaseViewModel.State>(State.Loading)
init { init {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
getSourcesWithNonLibraryManga.subscribe() getSourcesWithNonLibraryManga.subscribe()
.collectLatest { list -> .collectLatest { list ->
state.update { old -> state.update { old ->
@@ -252,10 +254,13 @@ class ClearDatabaseViewModel(
} }
} }
suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext { suspend fun removeMangaBySourceId(keepReadManga: Boolean) {
val state = state.value as? State.Ready ?: return@withNonCancellableContext 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) mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga)
historyRepository.deleteResetHistory() historyRepository.deleteResetHistory()
}.join()
} }
fun toggleSelection(source: Source) = state.update { state -> 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.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.launchIO
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@@ -90,7 +89,7 @@ class ExtensionStoresViewModel(
* Refreshes information for each repository. * Refreshes information for each repository.
*/ */
fun refreshRepos() { fun refreshRepos() {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
updateExtensionStores() updateExtensionStores()
} }
} }
@@ -99,14 +98,14 @@ class ExtensionStoresViewModel(
* Deletes the given repo from the database * Deletes the given repo from the database
*/ */
fun deleteRepo(baseUrl: String) { fun deleteRepo(baseUrl: String) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
removeExtensionStore(baseUrl) removeExtensionStore(baseUrl)
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
} }
} }
fun addFromDeeplink(storeIndexUrl: String) { fun addFromDeeplink(storeIndexUrl: String) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val alreadyExists = getExtensionStores.get().any { it.indexUrl == storeIndexUrl } val alreadyExists = getExtensionStores.get().any { it.indexUrl == storeIndexUrl }
dialog.update { ExtensionStoreDialog.Confirm(url = storeIndexUrl, alreadyExists = alreadyExists) } 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.Immutable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import cafe.adriel.voyager.navigator.LocalNavigator 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction import tachiyomi.presentation.core.components.LazyColumnWithAction
@@ -46,6 +48,7 @@ class CreateBackupScreen : Screen() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val viewModel = metroViewModel<CreateBackupViewModel>() val viewModel = metroViewModel<CreateBackupViewModel>()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
val chooseBackupDir = rememberLauncherForActivityResult( val chooseBackupDir = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/*"), contract = ActivityResultContracts.CreateDocument("application/*"),
@@ -75,6 +78,7 @@ class CreateBackupScreen : Screen() {
actionLabel = stringResource(MR.strings.action_create), actionLabel = stringResource(MR.strings.action_create),
actionEnabled = state.options.canCreate(), actionEnabled = state.options.canCreate(),
onClickAction = { onClickAction = {
scope.launch {
if (!BackupCreateWorker.isManualJobRunning(context)) { if (!BackupCreateWorker.isManualJobRunning(context)) {
try { try {
chooseBackupDir.launch(BackupCreator.getFilename()) chooseBackupDir.launch(BackupCreator.getFilename())
@@ -84,6 +88,7 @@ class CreateBackupScreen : Screen() {
} else { } else {
context.toast(MR.strings.backup_in_progress) context.toast(MR.strings.backup_in_progress)
} }
}
}, },
) { ) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { 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.data.backup.restore.RestoreOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.core.common.util.lang.launchIO import kotlinx.coroutines.launch
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction import tachiyomi.presentation.core.components.LazyColumnWithAction
@@ -193,7 +194,7 @@ class RestoreBackupViewModel(
} }
init { init {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
validate(uri.toUri()) validate(uri.toUri())
} }
} }
@@ -9,12 +9,16 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@@ -27,7 +31,9 @@ fun StorageInfo(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val storages = remember { DiskUtil.getExternalStorages(context) } val storages by produceState(emptyList<File>()) {
value = withContext(Dispatchers.IO) { DiskUtil.getExternalStorages(context) }
}
Column( Column(
modifier = modifier, modifier = modifier,
@@ -45,9 +51,14 @@ private fun StorageInfo(
) { ) {
val context = LocalContext.current 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 availableText = remember(available) { Formatter.formatFileSize(context, available) }
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
val totalText = remember(total) { Formatter.formatFileSize(context, total) } val totalText = remember(total) { Formatter.formatFileSize(context, total) }
Column( Column(
@@ -63,7 +74,7 @@ private fun StorageInfo(
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.fillMaxWidth() .fillMaxWidth()
.height(12.dp), .height(12.dp),
progress = { (1 - (available / total.toFloat())) }, progress = { if (total > 0) 1 - (available / total.toFloat()) else 0f },
) )
Text( Text(
@@ -54,7 +54,7 @@ fun UpdateScreen(
onSelectAll: (Boolean) -> Unit, onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit, onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean, onUpdateLibrary: suspend () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit, onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit, onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
@@ -68,11 +68,13 @@ fun UpdateScreen(
onSelectAll(false) onSelectAll(false)
} }
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
UpdatesAppBar( UpdatesAppBar(
onCalendarClicked = { onCalendarClicked() }, onCalendarClicked = { onCalendarClicked() },
onUpdateLibrary = { onUpdateLibrary() }, onUpdateLibrary = { scope.launch { onUpdateLibrary() } },
onFilterClicked = { onFilterClicked() }, onFilterClicked = { onFilterClicked() },
hasFilters = hasActiveFilters, hasFilters = hasActiveFilters,
actionModeCounter = state.selected.size, actionModeCounter = state.selected.size,
@@ -96,15 +98,13 @@ fun UpdateScreen(
when { when {
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
else -> { else -> {
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
PullRefresh( PullRefresh(
refreshing = isRefreshing, refreshing = isRefreshing,
onRefresh = { onRefresh = {
val started = onUpdateLibrary()
if (!started) return@PullRefresh
scope.launch { scope.launch {
if (!onUpdateLibrary()) return@launch
// Fake refresh status but hide it after a second as it's a long running task // Fake refresh status but hide it after a second as it's a long running task
isRefreshing = true isRefreshing = true
delay(1.seconds) delay(1.seconds)
+15 -4
View File
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.preference.PreferenceManager
import coil3.ImageLoader import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
@@ -70,6 +71,7 @@ import tachiyomi.presentation.widget.WidgetManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektScope import uy.kohesive.injekt.api.InjektScope
import java.security.Security import java.security.Security
import kotlin.concurrent.thread
import mihon.icons.materialsymbols.R as MaterialSymbolsR import mihon.icons.materialsymbols.R as MaterialSymbolsR
class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory, GraphProvider<AppGraph> { 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 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 @Inject private lateinit var widgetManager: WidgetManager
@@ -114,6 +117,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
if (packageName != process) WebView.setDataDirectorySuffix(process) 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)) Injekt = InjektScope(MetroInjektRegistrar(application = this, graphProvider = this))
graph.inject(this) graph.inject(this)
@@ -205,7 +214,9 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
override fun newImageLoader(context: Context): ImageLoader { override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(this).apply { 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 { components {
// NetworkFetcher.Factory // NetworkFetcher.Factory
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) 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.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
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() if (isAutoBackup && BackupRestoreWorker.isRunning(context.workManager)) return Result.retry()
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: getAutomaticBackupLocation() ?: withContext(Dispatchers.IO) { getAutomaticBackupLocation() }
?: return Result.failure() ?: return Result.failure()
setForegroundSafely() setForegroundSafely()
@@ -62,7 +64,10 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam
?: BackupOptions() ?: BackupOptions()
return try { 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) { if (!isAutoBackup) {
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
} }
@@ -93,7 +98,7 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam
} }
companion object { companion object {
fun isManualJobRunning(context: Context): Boolean { suspend fun isManualJobRunning(context: Context): Boolean {
return context.workManager.isRunning(TAG_MANUAL) 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.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
@@ -50,7 +52,9 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara
setForegroundSafely() setForegroundSafely()
return try { return try {
withContext(Dispatchers.IO) {
backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options) backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options)
}
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) {
@@ -79,7 +83,7 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara
} }
companion object { companion object {
fun isRunning(workManager: WorkManager): Boolean { suspend fun isRunning(workManager: WorkManager): Boolean {
return workManager.isRunning(TAG) return workManager.isRunning(TAG)
} }
@@ -36,17 +36,20 @@ class ChapterCache(
) { ) {
/** Cache class used for cache management. */ /** Cache class used for cache management. */
private val diskCache = DiskLruCache.open( private val diskCache by lazy {
DiskLruCache.open(
File(context.cacheDir, "chapter_disk_cache"), File(context.cacheDir, "chapter_disk_cache"),
PARAMETER_APP_VERSION, PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT, PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE, PARAMETER_CACHE_SIZE,
) )
}
/** /**
* Returns directory of cache. * Returns directory of cache.
*/ */
private val cacheDir: File = diskCache.directory private val cacheDir: File
get() = diskCache.directory
/** /**
* Returns real size of directory. * Returns real size of directory.
@@ -5,6 +5,9 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.util.storage.DiskUtil 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 tachiyomi.domain.manga.model.Manga
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -20,7 +23,10 @@ import java.io.InputStream
*/ */
@Inject @Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class CoverCache(private val context: Context) { class CoverCache(
@AppCoroutineScope private val scope: CoroutineScope,
private val context: Context,
) {
companion object { companion object {
private const val COVERS_DIR = "covers" private const val COVERS_DIR = "covers"
@@ -30,9 +36,28 @@ class CoverCache(private val context: Context) {
/** /**
* Cache directory used for cache management. * 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. * Returns the cover from cache.
@@ -56,6 +81,12 @@ class CoverCache(private val context: Context) {
return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(mangaId.toString())) 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. * 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) @Throws(IOException::class)
fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) { fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
getCustomCoverFile(manga.id).outputStream().use { val file = getCustomCoverFile(manga.id)
file.outputStream().use {
inputStream.copyTo(it) 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. * @return whether the cover was deleted.
*/ */
fun deleteCustomCover(mangaId: Long?): Boolean { fun deleteCustomCover(mangaId: Long?): Boolean {
return getCustomCoverFile(mangaId).let { val file = getCustomCoverFile(mangaId)
it.exists() && it.delete() 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 { private fun getCacheDir(dir: String): File {
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
private val coverCache: CoverCache, private val coverCache: CoverCache,
) : Keyer<MangaCover> { ) : Keyer<MangaCover> {
override fun key(data: MangaCover, options: Options): String { 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}" "${data.mangaId};${data.lastModified}"
} else { } else {
"${data.url};${data.lastModified}" "${data.url};${data.lastModified}"
@@ -11,6 +11,7 @@ import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -43,8 +45,6 @@ import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.storage.nameWithoutExtension 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.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -99,11 +99,13 @@ class DownloadCache(
get() = AtomicFile(File(context.cacheDir, "dl_index_cache_v3")) get() = AtomicFile(File(context.cacheDir, "dl_index_cache_v3"))
private val rootDownloadsDirMutex = Mutex() private val rootDownloadsDirMutex = Mutex()
private var rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory())
@Volatile
private var rootDownloadsDir = RootDirectory(null)
init { init {
// Attempt to read cache file // Attempt to read cache file
initJob = scope.launchIO { initJob = scope.launch(Dispatchers.IO) {
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
try { try {
if (diskCacheFile.baseFile.exists()) { if (diskCacheFile.baseFile.exists()) {
@@ -252,11 +254,8 @@ class DownloadCache(
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { mangaDir.chapterDirs -=
if (it in mangaDir.chapterDirs) { provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).toSet()
mangaDir.chapterDirs -= it
}
}
} }
notifyChanges() notifyChanges()
@@ -272,13 +271,9 @@ class DownloadCache(
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
chapters.forEach { chapter -> mangaDir.chapterDirs -= chapters
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach { .flatMap { provider.getValidChapterDirNames(it.name, it.scanlator, it.url) }
if (it in mangaDir.chapterDirs) { .toSet()
mangaDir.chapterDirs -= it
}
}
}
} }
notifyChanges() notifyChanges()
@@ -312,7 +307,7 @@ class DownloadCache(
rootDownloadsDirMutex.withLock { rootDownloadsDirMutex.withLock {
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
val oldMangaDirName = provider.getMangaDirName(manga.title) val oldMangaDirName = provider.getMangaDirName(manga.title)
var oldChapterDirs: MutableSet<String>? = null var oldChapterDirs: Set<String>? = null
// Save the old name's cached chapter dirs // Save the old name's cached chapter dirs
if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) { if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) {
oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs
@@ -347,22 +342,22 @@ class DownloadCache(
fun invalidateCache() { fun invalidateCache() {
lastRenew = 0L lastRenew = 0L
renewalJob?.cancel() renewalJob?.cancel()
diskCacheFile.delete() renewCache(deleteDiskCache = true)
renewCache()
} }
/** /**
* Renews the downloads cache. * Renews the downloads cache.
*/ */
private fun renewCache() { private fun renewCache(deleteDiskCache: Boolean = false) {
// Avoid renewing cache if in the process nor too often // Avoid renewing cache if in the process nor too often
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
return return
} }
renewalJob = scope.launchIO { renewalJob = scope.launch(Dispatchers.IO) {
// The disk index is older than this scan and would replace it if read afterwards // The disk index is older than this scan and would replace it if read afterwards
initJob.join() initJob.join()
if (deleteDiskCache) diskCacheFile.delete()
if (lastRenew == 0L) { if (lastRenew == 0L) {
_isInitializing.emit(true) _isInitializing.emit(true)
@@ -407,7 +402,7 @@ class DownloadCache(
else -> null else -> null
} }
} }
.toMutableSet() .toSet()
mangaDir.chapterDirs = chapterDirs mangaDir.chapterDirs = chapterDirs
} }
@@ -424,7 +419,8 @@ class DownloadCache(
if (exception != null && exception !is CancellationException) { if (exception != null && exception !is CancellationException) {
logcat(LogPriority.ERROR, exception) { "DownloadCache: failed to create cache" } 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() notifyChanges()
} }
} }
@@ -438,16 +434,14 @@ class DownloadCache(
} }
private fun notifyChanges() { private fun notifyChanges() {
scope.launchNonCancellable { _changes.trySend(Unit)
_changes.send(Unit)
}
updateDiskCache() updateDiskCache()
} }
private var updateDiskCacheJob: Job? = null private var updateDiskCacheJob: Job? = null
private fun updateDiskCache() { private fun updateDiskCache() {
updateDiskCacheJob?.cancel() updateDiskCacheJob?.cancel()
updateDiskCacheJob = scope.launchIO { updateDiskCacheJob = scope.launch(Dispatchers.IO) {
delay(1.seconds) delay(1.seconds)
val bytes = rootDownloadsDirMutex.withLock { val bytes = rootDownloadsDirMutex.withLock {
ProtoBuf.encodeToByteArray(rootDownloadsDir) ProtoBuf.encodeToByteArray(rootDownloadsDir)
@@ -473,6 +467,7 @@ class DownloadCache(
private class RootDirectory( private class RootDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?, val dir: UniFile?,
@Volatile
var sourceDirs: Map<Long, SourceDirectory> = mapOf(), var sourceDirs: Map<Long, SourceDirectory> = mapOf(),
) )
@@ -483,6 +478,7 @@ private class RootDirectory(
private class SourceDirectory( private class SourceDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?, val dir: UniFile?,
@Volatile
var mangaDirs: Map<String, MangaDirectory> = mapOf(), var mangaDirs: Map<String, MangaDirectory> = mapOf(),
) )
@@ -493,7 +489,9 @@ private class SourceDirectory(
private class MangaDirectory( private class MangaDirectory(
@Serializable(with = UniFileAsStringSerializer::class) @Serializable(with = UniFileAsStringSerializer::class)
val dir: UniFile?, 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?> { 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.Source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@@ -17,12 +17,11 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.extension 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.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
@@ -42,7 +41,6 @@ import tachiyomi.i18n.MR
@Inject @Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DownloadManager( class DownloadManager(
@AppCoroutineScope private val scope: CoroutineScope,
private val context: Context, private val context: Context,
private val provider: DownloadProvider, private val provider: DownloadProvider,
private val cache: DownloadCache, private val cache: DownloadCache,
@@ -73,15 +71,14 @@ class DownloadManager(
/** /**
* Tells the downloader to pause downloads. * Tells the downloader to pause downloads.
*/ */
fun pauseDownloads() { suspend fun pauseDownloads() {
downloader.pause() downloader.pause()
downloader.stop()
} }
/** /**
* Empties the download queue. * Empties the download queue.
*/ */
fun clearQueue() { suspend fun clearQueue() {
downloader.clearQueue() downloader.clearQueue()
downloader.stop() downloader.stop()
} }
@@ -103,14 +100,12 @@ class DownloadManager(
return queueState.value.associateBy { it.chapter.id } return queueState.value.associateBy { it.chapter.id }
} }
fun startDownloadNow(chapterId: Long) { suspend fun startDownloadNow(chapterId: Long) {
val existingDownload = getQueuedDownloadOrNull(chapterId)
// If not in queue try to start a new download // If not in queue try to start a new download
val toAdd = existingDownload ?: runBlocking { downloadFromChapterId(chapterId) } ?: return val toAdd = getQueuedDownloadOrNull(chapterId) ?: downloadFromChapterId(chapterId) ?: return
queueState.value.toMutableList().apply { downloader.updateQueue { queue ->
existingDownload?.let { remove(it) } val (existing, others) = queue.partition { it.chapter.id == chapterId }
add(0, toAdd) listOf(existing.firstOrNull() ?: toAdd) + others
reorderQueue(this)
} }
startDownloads() startDownloads()
} }
@@ -126,10 +121,15 @@ class DownloadManager(
/** /**
* Reorders the download queue. * 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>) { suspend fun reorderQueue(downloads: List<Download>) {
downloader.updateQueue(downloads) 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. * @param downloads the list of downloads to enqueue.
*/ */
fun addDownloadsToStartOfQueue(downloads: List<Download>) { suspend fun addDownloadsToStartOfQueue(downloads: List<Download>) {
if (downloads.isEmpty()) return if (downloads.isEmpty()) return
queueState.value.toMutableList().apply { downloader.updateQueue { queue ->
addAll(0, downloads) val chapterIds = downloads.mapTo(HashSet()) { it.chapter.id }
reorderQueue(this) downloads + queue.filter { it.chapter.id !in chapterIds }
} }
startDownloads() startDownloads()
} }
@@ -167,10 +167,12 @@ class DownloadManager(
* @param chapter the downloaded chapter. * @param chapter the downloaded chapter.
* @return the list of pages from the 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 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() } } .filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } }
}
if (files.isEmpty()) { if (files.isEmpty()) {
throw Exception(context.stringResource(MR.strings.page_list_empty_error)) 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 mangaTitle the title of the manga to query.
* @param source the source of the chapter. * @param source the source of the chapter.
*/ */
fun isChapterDownloadedOnDisk( suspend fun isChapterDownloadedOnDisk(
chapterName: String, chapterName: String,
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String, chapterUrl: String,
@@ -244,8 +246,8 @@ class DownloadManager(
return cache.getDownloadCount(manga) return cache.getDownloadCount(manga)
} }
fun cancelQueuedDownloads(downloads: List<Download>) { suspend fun cancelQueuedDownloads(downloads: List<Download>) {
removeFromDownloadQueue(downloads.map { it.chapter }) downloader.dequeue(downloads.map { it.chapter })
} }
/** /**
@@ -255,14 +257,14 @@ class DownloadManager(
* @param manga the manga of the chapters. * @param manga the manga of the chapters.
* @param source the source of the chapters. * @param source the source of the chapters.
*/ */
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) { suspend fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
scope.launchIO { withContext(Dispatchers.IO) {
val filteredChapters = getChaptersToDelete(chapters, manga) val filteredChapters = getChaptersToDelete(chapters, manga)
if (filteredChapters.isEmpty()) { if (filteredChapters.isEmpty()) {
return@launchIO return@withContext
} }
removeFromDownloadQueue(filteredChapters) downloader.dequeue(filteredChapters)
val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source) val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source)
chapterDirs.forEach { it.delete() } chapterDirs.forEach { it.delete() }
@@ -282,8 +284,8 @@ class DownloadManager(
* @param source the source of the manga. * @param source the source of the manga.
* @param removeQueued whether to also remove queued downloads. * @param removeQueued whether to also remove queued downloads.
*/ */
fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) { suspend fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) {
scope.launchIO { withContext(Dispatchers.IO) {
if (removeQueued) { if (removeQueued) {
downloader.removeFromQueue(manga) 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. * Adds a list of chapters to be deleted later.
* *
@@ -343,18 +328,18 @@ class DownloadManager(
* @param oldSource the old source. * @param oldSource the old source.
* @param newSource the new source. * @param newSource the new source.
*/ */
fun renameSource(oldSource: Source, newSource: Source) { suspend fun renameSource(oldSource: Source, newSource: Source) = withContext(Dispatchers.IO) {
val oldFolder = provider.findSourceDir(oldSource) ?: return val oldFolder = provider.findSourceDir(oldSource) ?: return@withContext
val newName = provider.getSourceDirName(newSource) val newName = provider.getSourceDirName(newSource)
if (oldFolder.name == newName) return if (oldFolder.name == newName) return@withContext
val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true) val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true)
if (capitalizationChanged) { if (capitalizationChanged) {
val tempName = newName + Downloader.TMP_DIR_SUFFIX val tempName = newName + Downloader.TMP_DIR_SUFFIX
if (!oldFolder.renameTo(tempName)) { if (!oldFolder.renameTo(tempName)) {
logcat(LogPriority.ERROR) { "Failed to rename source download folder: ${oldFolder.name}" } 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 manga the manga
* @param newTitle the new manga title. * @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 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) 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 // just to be safe, don't allow downloads for this manga while renaming it
downloader.removeFromQueue(manga) downloader.removeFromQueue(manga)
@@ -384,7 +369,7 @@ class DownloadManager(
val tempName = newName + Downloader.TMP_DIR_SUFFIX val tempName = newName + Downloader.TMP_DIR_SUFFIX
if (!oldFolder.renameTo(tempName)) { if (!oldFolder.renameTo(tempName)) {
logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" } 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 oldChapter the existing chapter with the old name.
* @param newChapter the target chapter with the new 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 oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url)
val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e -> val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" } logcat(LogPriority.ERROR, e) {
return "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 // Assume there's only 1 version of the chapter name formats present
val oldDownload = oldNames.asSequence() val oldDownload = oldNames.asSequence()
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() ?: return .firstOrNull() ?: return@withContext
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") { if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz" newName += ".cbz"
} }
if (oldDownload.name == newName) return if (oldDownload.name == newName) return@withContext
if (oldDownload.renameTo(newName)) { if (oldDownload.renameTo(newName)) {
cache.removeChapter(oldChapter, manga) cache.removeChapter(oldChapter, manga)
@@ -25,7 +25,7 @@ class DownloadPendingDeleter(
/** /**
* Preferences used to store the list of chapters to delete. * 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. * 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.source.Source
import eu.kanade.tachiyomi.util.lang.Hash.md5 import eu.kanade.tachiyomi.util.lang.Hash.md5
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.displayablePath import tachiyomi.core.common.storage.displayablePath
@@ -42,7 +44,10 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param source the source of the manga. * @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 val downloadsDir = downloadsDir
if (downloadsDir == null) { if (downloadsDir == null) {
logcat(LogPriority.ERROR) { "Failed to create download directory" } logcat(LogPriority.ERROR) { "Failed to create download directory" }
@@ -79,8 +84,8 @@ class DownloadProvider(
* *
* @param source the source to query. * @param source the source to query.
*/ */
fun findSourceDir(source: Source): UniFile? { suspend fun findSourceDir(source: Source): UniFile? = withContext(Dispatchers.IO) {
return downloadsDir?.findFile(getSourceDirName(source)) downloadsDir?.findFile(getSourceDirName(source))
} }
/** /**
@@ -89,9 +94,8 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param source the source of the manga. * @param source the source of the manga.
*/ */
fun findMangaDir(mangaTitle: String, source: Source): UniFile? { suspend fun findMangaDir(mangaTitle: String, source: Source): UniFile? = withContext(Dispatchers.IO) {
val sourceDir = findSourceDir(source) findSourceDir(source)?.findFile(getMangaDirName(mangaTitle))
return sourceDir?.findFile(getMangaDirName(mangaTitle))
} }
/** /**
@@ -102,15 +106,15 @@ class DownloadProvider(
* @param mangaTitle the title of the manga to query. * @param mangaTitle the title of the manga to query.
* @param source the source of the chapter. * @param source the source of the chapter.
*/ */
fun findChapterDir( suspend fun findChapterDir(
chapterName: String, chapterName: String,
chapterScanlator: String?, chapterScanlator: String?,
chapterUrl: String, chapterUrl: String,
mangaTitle: String, mangaTitle: String,
source: Source, source: Source,
): UniFile? { ): UniFile? = withContext(Dispatchers.IO) {
val mangaDir = findMangaDir(mangaTitle, source) val mangaDir = findMangaDir(mangaTitle, source)
return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence() getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
.mapNotNull { mangaDir?.findFile(it) } .mapNotNull { mangaDir?.findFile(it) }
.firstOrNull() .firstOrNull()
} }
@@ -122,9 +126,13 @@ class DownloadProvider(
* @param manga the manga of the chapter. * @param manga the manga of the chapter.
* @param source the source of the chapter. * @param source the source of the chapter.
*/ */
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> { suspend fun findChapterDirs(
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList() chapters: List<Chapter>,
return mangaDir to chapters.mapNotNull { 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() getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
.mapNotNull { mangaDir.findFile(it) } .mapNotNull { mangaDir.findFile(it) }
.firstOrNull() .firstOrNull()
@@ -30,7 +30,7 @@ class DownloadStore(
/** /**
* Preference file where active downloads are stored. * 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. * 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.activeNetworkState
import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
@@ -74,7 +76,10 @@ class DownloadWorker(context: Context, workerParams: WorkerParameters) : Corouti
networkIssue = networkIssue() networkIssue = networkIssue()
} }
} finally { } 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() return Result.success()
@@ -21,6 +21,7 @@ import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
@@ -39,9 +40,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.archive.ZipWriter import mihon.core.archive.ZipWriter
@@ -49,7 +53,6 @@ import mihon.core.metro.AppCoroutineScope
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource 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.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
@@ -93,7 +96,12 @@ class Downloader(
private val _queueState = MutableStateFlow<List<Download>>(emptyList()) private val _queueState = MutableStateFlow<List<Download>>(emptyList())
val queueState = _queueState.asStateFlow() 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 // Kept when canceled, so the next job can wait for it to finish
@Volatile
private var downloaderJob: Job? = null private var downloaderJob: Job? = null
/** /**
@@ -109,8 +117,8 @@ class Downloader(
var isPaused: Boolean = false var isPaused: Boolean = false
init { init {
scope.launch { scope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
addAllToQueue(store.restore()) addAllToQueue(withContext(Dispatchers.IO) { store.restore() })
} }
} }
@@ -120,7 +128,42 @@ class Downloader(
* *
* @return true if the downloader is started, false otherwise. * @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()) { if (isRunning || queueState.value.isEmpty()) {
return false return false
} }
@@ -135,10 +178,7 @@ class Downloader(
return pending.isNotEmpty() return pending.isNotEmpty()
} }
/** private fun stopLocked(reason: String? = null) {
* Stops the downloader.
*/
fun stop(reason: String? = null) {
cancelDownloaderJob() cancelDownloaderJob()
queueState.value queueState.value
.filter { it.status == Download.State.DOWNLOADING } .filter { it.status == Download.State.DOWNLOADING }
@@ -158,10 +198,7 @@ class Downloader(
isPaused = false isPaused = false
} }
/** private fun pauseLocked() {
* Pauses the downloader
*/
fun pause() {
cancelDownloaderJob() cancelDownloaderJob()
queueState.value queueState.value
.filter { it.status == Download.State.DOWNLOADING } .filter { it.status == Download.State.DOWNLOADING }
@@ -169,16 +206,6 @@ class Downloader(
isPaused = true isPaused = true
} }
/**
* Removes everything from the queue.
*/
fun clearQueue() {
cancelDownloaderJob()
internalClearQueue()
notifier.dismissProgress()
}
/** /**
* Prepares the subscriptions to start downloading. * 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 // 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. // cancelable, so a job canceled while waiting still finishes after the one before it.
withContext(NonCancellable) { previousJob?.join() } withContext(NonCancellable) { previousJob?.join() }
val owner = coroutineContext.job
val activeDownloadsFlow = combine( val activeDownloadsFlow = combine(
queueState, queueState,
@@ -238,7 +266,7 @@ class Downloader(
val sourcesToStart = activeSources.filter { it !in sourceJobs } val sourcesToStart = activeSources.filter { it !in sourceJobs }
sourcesToStart.forEach { source -> sourcesToStart.forEach { source ->
sourceJobs[source] = launchSourceJob(source, downloadJobs) sourceJobs[source] = launchSourceJob(source, downloadJobs, owner)
} }
} }
} }
@@ -253,7 +281,8 @@ class Downloader(
private fun CoroutineScope.launchSourceJob( private fun CoroutineScope.launchSourceJob(
source: HttpSource, source: HttpSource,
downloadJobs: MutableMap<Download, Job>, downloadJobs: MutableMap<Download, Job>,
) = launchIO { owner: Job,
) = launch(Dispatchers.IO) {
val slots = Semaphore(downloadPreferences.parallelPageLimit.get()) val slots = Semaphore(downloadPreferences.parallelPageLimit.get())
while (true) { while (true) {
// A canceled job can mark a chapter as downloading after it's been reset, so that counts as waiting too // 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() .filterNotNull()
.first() .first()
val pagesStarted = CompletableDeferred<Unit>() val pagesStarted = CompletableDeferred<Unit>()
val job = launchDownloadJob(download, slots, pagesStarted) val job = launchDownloadJob(download, slots, pagesStarted, owner)
downloadJobs[download] = job 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() pagesStarted.await()
} }
} }
@@ -279,22 +312,24 @@ class Downloader(
download: Download, download: Download,
slots: Semaphore, slots: Semaphore,
pagesStarted: CompletableDeferred<Unit>, pagesStarted: CompletableDeferred<Unit>,
) = launchIO { owner: Job,
) = launch(Dispatchers.IO) {
try { try {
// Removed from the queue between being picked and starting
if (download !in queueState.value) return@launch
downloadChapter(download, slots, pagesStarted) downloadChapter(download, slots, pagesStarted)
// Remove successful download from queue // Remove successful download from queue
if (download.status == Download.State.DOWNLOADED) { if (download.status == Download.State.DOWNLOADED) {
removeFromQueue(download) removeFromQueue(download)
} }
if (areAllDownloadsFinished()) { stopIfFinished(owner)
stop()
}
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
notifier.onError(e.message) notifier.onError(e.message)
stop() stopIfCurrent(owner)
} finally { } finally {
// Also when the chapter fails before its pages, so the source moves on to the next one // Also when the chapter fails before its pages, so the source moves on to the next one
pagesStarted.complete(Unit) pagesStarted.complete(Unit)
@@ -321,9 +356,11 @@ class Downloader(
val source = sourceManager.get(manga.source) as? HttpSource ?: return false val source = sourceManager.get(manga.source) as? HttpSource ?: return false
val wasEmpty = queueState.value.isEmpty() val wasEmpty = queueState.value.isEmpty()
val chaptersToQueue = chapters.asSequence() val chaptersToQueue = withContext(Dispatchers.IO) {
chapters
// Filter out those already downloaded. // Filter out those already downloaded.
.filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null } .filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null }
.asSequence()
// Add chapters to queue from the start. // Add chapters to queue from the start.
.sortedByDescending { it.sourceOrder } .sortedByDescending { it.sourceOrder }
// Filter out those already enqueued. // Filter out those already enqueued.
@@ -331,6 +368,7 @@ class Downloader(
// Create a download for each one. // Create a download for each one.
.map { Download(source, manga, it) } .map { Download(source, manga, it) }
.toList() .toList()
}
if (chaptersToQueue.isNotEmpty()) { if (chaptersToQueue.isNotEmpty()) {
addAllToQueue(chaptersToQueue) addAllToQueue(chaptersToQueue)
@@ -725,12 +763,14 @@ class Downloader(
} }
private fun addAllToQueue(downloads: List<Download>) { private fun addAllToQueue(downloads: List<Download>) {
_queueState.update { _queueState.update { queue ->
downloads.forEach { download -> // 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 download.status = Download.State.QUEUE
} }
store.addAll(downloads) store.addAll(toAdd)
it + downloads 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 } val chapterIds = chapters.map { it.id }
removeFromQueueIf { it.chapter.id in chapterIds } removeFromQueueIf { it.chapter.id in chapterIds }
} }
fun removeFromQueue(manga: Manga) { suspend fun removeFromQueue(manga: Manga) = lock.withLock {
removeFromQueueIf { it.manga.id == manga.id } removeFromQueueIf { it.manga.id == manga.id }
} }
@@ -778,21 +818,44 @@ class Downloader(
} }
} }
fun updateQueue(downloads: List<Download>) { /**
val wasRunning = isRunning * 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.
if (downloads.isEmpty()) { */
clearQueue() suspend fun updateQueue(transform: (List<Download>) -> List<Download>) = editQueue {
stop() // One update, since chapters are queued without the lock
return _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() * Removes [chapters] from the queue, keeping the state of the downloads that stay.
addAllToQueue(downloads) */
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) { 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.getBitmapOrNull
import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notify 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.Constants
import tachiyomi.core.common.i18n.pluralStringResource import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -46,7 +43,6 @@ import mihon.icons.materialsymbols.R as MaterialSymbolsR
@Inject @Inject
class LibraryUpdateNotifier( class LibraryUpdateNotifier(
@AppCoroutineScope private val scope: CoroutineScope,
private val context: Context, private val context: Context,
private val securityPreferences: SecurityPreferences, private val securityPreferences: SecurityPreferences,
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
@@ -173,7 +169,7 @@ class LibraryUpdateNotifier(
* *
* @param updates a list of manga with new updates. * @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 // Parent group notification
context.notify( context.notify(
Notifications.ID_NEW_CHAPTERS, Notifications.ID_NEW_CHAPTERS,
@@ -216,7 +212,6 @@ class LibraryUpdateNotifier(
// Per-manga notification // Per-manga notification
if (!securityPreferences.hideNotificationContent.get()) { if (!securityPreferences.hideNotificationContent.get()) {
scope.launchUI {
context.notify( context.notify(
updates.map { (manga, chapters) -> updates.map { (manga, chapters) ->
NotificationManagerCompat.NotificationWithIdAndTag( NotificationManagerCompat.NotificationWithIdAndTag(
@@ -227,7 +222,6 @@ class LibraryUpdateNotifier(
) )
} }
} }
}
private suspend fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification { private suspend fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification {
val icon = getMangaIcon(manga) 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.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority import logcat.LogPriority
@@ -47,7 +50,6 @@ import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.source.interactor.UpdateMangaFromRemote import mihon.domain.source.interactor.UpdateMangaFromRemote
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.model.Chapter 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) val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
addMangaToQueue(categoryId) addMangaToQueue(categoryId)
return withIOContext { return withContext(Dispatchers.IO) {
try { try {
updateChapterList() updateChapterList()
Result.success() Result.success()
@@ -290,7 +292,10 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
hasDownloads.store(true) hasDownloads.store(true)
} }
// Sources update concurrently, and getAndSet is a separate read and write
synchronized(libraryPreferences.newUpdatesCount) {
libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size } libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size }
}
// Convert to the manga that contains new chapters // Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray()) 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, workManager: WorkManager,
category: Category? = null, category: Category? = null,
): Boolean { ): Boolean {
@@ -507,12 +512,12 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
return true return true
} }
fun stop(context: Context) { suspend fun stop(context: Context) {
val workManager = context.workManager val workManager = context.workManager
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG)) val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
.addStates(listOf(WorkInfo.State.RUNNING)) .addStates(listOf(WorkInfo.State.RUNNING))
.build() .build()
workManager.getWorkInfos(workQuery).get() workManager.getWorkInfosFlow(workQuery).first()
// Should only return one work but just in case // Should only return one work but just in case
.forEach { .forEach {
workManager.cancelWorkById(it.id) 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.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.AppGraph import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
import mihon.domain.source.interactor.UpdateMangaFromRemote import mihon.domain.source.interactor.UpdateMangaFromRemote
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga
@@ -58,7 +59,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) :
addMangaToQueue() addMangaToQueue()
return withIOContext { return withContext(Dispatchers.IO) {
try { try {
updateMetadata() updateMetadata()
Result.success() Result.success()
@@ -172,7 +173,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) :
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 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)) { if (workManager.isRunning(TAG)) {
// Already running either as a scheduled or manual job // Already running either as a scheduled or manual job
return false 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.getParcelableExtraCompat
import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.Constants 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.interactor.GetManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/** /**
@@ -66,9 +63,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Resume the download service // Resume the download service
ACTION_RESUME_DOWNLOADS -> downloadManager.startDownloads() ACTION_RESUME_DOWNLOADS -> downloadManager.startDownloads()
// Pause the download service // Pause the download service
ACTION_PAUSE_DOWNLOADS -> downloadManager.pauseDownloads() ACTION_PAUSE_DOWNLOADS -> launchAsync { downloadManager.pauseDownloads() }
// Clear the download queue // Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue() ACTION_CLEAR_DOWNLOADS -> launchAsync { downloadManager.clearQueue() }
// Launch share activity and dismiss notification // Launch share activity and dismiss notification
ACTION_SHARE_IMAGE -> ACTION_SHARE_IMAGE ->
shareImage( shareImage(
@@ -85,14 +82,6 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_CANCEL_RESTORE -> cancelRestore(context) ACTION_CANCEL_RESTORE -> cancelRestore(context)
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) 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 // Mark updated manga chapters as read
ACTION_MARK_AS_READ -> { ACTION_MARK_AS_READ -> {
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
@@ -149,26 +138,6 @@ class NotificationReceiver : BroadcastReceiver() {
context.startActivity(uri.toShareIntent(context, fileMimeType)) 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. * Method called when user wants to stop a backup restore job.
* *
@@ -184,8 +153,10 @@ class NotificationReceiver : BroadcastReceiver() {
* @param context context of application * @param context context of application
*/ */
private fun cancelLibraryUpdate(context: Context) { private fun cancelLibraryUpdate(context: Context) {
launchAsync {
LibraryUpdateWorker.stop(context) LibraryUpdateWorker.stop(context)
} }
}
/** /**
* Method called when user wants to mark manga chapters as read * 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_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" 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_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER"
private const val ACTION_OPEN_ENTRY = "$ID.$NAME.ACTION_OPEN_ENTRY" 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_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_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_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" 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.network.NetworkHelper
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import okhttp3.OkHttpClient 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.core.common.util.system.logcat
import tachiyomi.domain.track.interactor.UpsertTrack import tachiyomi.domain.track.interactor.UpsertTrack
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@@ -115,7 +115,7 @@ abstract class BaseTracker(
try { try {
addTracks.bind(this, item, mangaId) addTracks.bind(this, item, mangaId)
} catch (e: Throwable) { } catch (e: Throwable) {
withUIContext { withContext(Dispatchers.Main) {
context.toast(e.message) context.toast(e.message)
} }
} }
@@ -165,7 +165,7 @@ abstract class BaseTracker(
updateRemote(track) updateRemote(track)
} }
private suspend fun updateRemote(track: Track): Unit = withIOContext { private suspend fun updateRemote(track: Track): Unit = withContext(Dispatchers.IO) {
try { try {
update(track) update(track)
track.toDomainTrack(idRequired = false)?.let { track.toDomainTrack(idRequired = false)?.let {
@@ -173,7 +173,7 @@ abstract class BaseTracker(
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" } logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
withUIContext { withContext(Dispatchers.Main) {
context.toast(e.message) 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.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
@@ -26,7 +28,6 @@ import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class BangumiApi( class BangumiApi(
@@ -40,7 +41,7 @@ class BangumiApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
val body = buildJsonObject { val body = buildJsonObject {
put("type", track.toApiStatus()) put("type", track.toApiStatus())
@@ -52,13 +53,13 @@ class BangumiApi(
.toRequestBody() .toRequestBody()
// Returns with 202 Accepted on success with no body // Returns with 202 Accepted on success with no body
authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess() .awaitSuccess().close()
track track
} }
} }
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
val body = buildJsonObject { val body = buildJsonObject {
put("type", track.toApiStatus()) put("type", track.toApiStatus())
@@ -76,7 +77,7 @@ class BangumiApi(
.build() .build()
// Returns with 204 No Content // Returns with 204 No Content
authClient.newCall(request) authClient.newCall(request)
.awaitSuccess() .awaitSuccess().close()
track track
} }
@@ -87,7 +88,7 @@ class BangumiApi(
// but that has been the case since 2022 with few significant // but that has been the case since 2022 with few significant
// changes to the schema for this endpoint since // changes to the schema for this endpoint since
// "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动" // "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动"
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_URL/v0/search/subjects?limit=20" val url = "$API_URL/v0/search/subjects?limit=20"
val body = buildJsonObject { val body = buildJsonObject {
put("keyword", search) put("keyword", search)
@@ -112,7 +113,7 @@ class BangumiApi(
} }
suspend fun getMangaDetails(id: Int): TrackSearch? { suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_URL/v0/subjects/$id" val url = "$API_URL/v0/subjects/$id"
with(json) { with(json) {
@@ -126,7 +127,7 @@ class BangumiApi(
} }
suspend fun statusLibManga(track: Track, username: String): Track? { 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}" val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
with(json) { with(json) {
try { try {
@@ -152,7 +153,7 @@ class BangumiApi(
} }
suspend fun accessToken(code: String): BGMOAuth { suspend fun accessToken(code: String): BGMOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("grant_type", "authorization_code") .add("grant_type", "authorization_code")
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
@@ -169,7 +170,7 @@ class BangumiApi(
} }
suspend fun getCurrentUser(): BGMUser { suspend fun getCurrentUser(): BGMUser {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
authClient.newCall(GET("$API_URL/v0/me")) authClient.newCall(GET("$API_URL/v0/me"))
.awaitSuccess() .awaitSuccess()
@@ -15,6 +15,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
*/ */
@Volatile
private var oauth: BGMOAuth? = bangumi.restoreToken() private var oauth: BGMOAuth? = bangumi.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response { 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") var currAuth: BGMOAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!)) currAuth = refreshToken(chain, currAuth)
if (response.isSuccessful) {
currAuth = with(json) {
response.parseAs<BGMOAuth>()
}
newAuth(currAuth)
} else {
response.close()
}
} }
return originalRequest.newBuilder() return originalRequest.newBuilder()
@@ -44,6 +37,18 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
.let(chain::proceed) .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?) { fun newAuth(oauth: BGMOAuth?) {
this.oauth = oauth this.oauth = oauth
bangumi.saveToken(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.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
@@ -27,7 +29,6 @@ import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack import tachiyomi.domain.track.model.Track as DomainTrack
@@ -37,7 +38,7 @@ class HikkaApi(
interceptor: HikkaInterceptor, interceptor: HikkaInterceptor,
) { ) {
suspend fun getCurrentUser(): HKUser { suspend fun getCurrentUser(): HKUser {
return withIOContext { return withContext(Dispatchers.IO) {
val request = Request.Builder() val request = Request.Builder()
.url("${BASE_API_URL}/user/me") .url("${BASE_API_URL}/user/me")
.get() .get()
@@ -51,7 +52,7 @@ class HikkaApi(
} }
suspend fun accessToken(reference: String): HKOAuth { suspend fun accessToken(reference: String): HKOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
client.newCall(authTokenCreate(reference)) client.newCall(authTokenCreate(reference))
.awaitSuccess() .awaitSuccess()
@@ -61,7 +62,7 @@ class HikkaApi(
} }
suspend fun searchManga(query: String): List<TrackSearch> { suspend fun searchManga(query: String): List<TrackSearch> {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$BASE_API_URL/manga".toUri().buildUpon() val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendQueryParameter("page", "1") .appendQueryParameter("page", "1")
.appendQueryParameter("size", "50") .appendQueryParameter("size", "50")
@@ -101,7 +102,7 @@ class HikkaApi(
} }
suspend fun getMangaDetails(slug: String): TrackSearch? { suspend fun getMangaDetails(slug: String): TrackSearch? {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$BASE_API_URL/manga/$slug" val url = "$BASE_API_URL/manga/$slug"
with(json) { with(json) {
@@ -120,7 +121,7 @@ class HikkaApi(
} }
suspend fun getRead(track: Track): HKRead? { suspend fun getRead(track: Track): HKRead? {
return withIOContext { return withContext(Dispatchers.IO) {
val slug = track.tracking_url.split("/")[4] val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build() val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
with(json) { with(json) {
@@ -140,7 +141,7 @@ class HikkaApi(
} }
suspend fun getManga(track: Track): TrackSearch { suspend fun getManga(track: Track): TrackSearch {
return withIOContext { return withContext(Dispatchers.IO) {
val slug = track.tracking_url.split("/")[4] val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon() val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon()
.build() .build()
@@ -155,19 +156,19 @@ class HikkaApi(
} }
suspend fun deleteUserManga(track: DomainTrack) { suspend fun deleteUserManga(track: DomainTrack) {
return withIOContext { return withContext(Dispatchers.IO) {
val slug = track.remoteUrl.split("/")[4] val slug = track.remoteUrl.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon() val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
.build() .build()
authClient.newCall(DELETE(url.toString())) authClient.newCall(DELETE(url.toString()))
.awaitSuccess() .awaitSuccess().close()
} }
} }
suspend fun addUserManga(track: Track): Track { suspend fun addUserManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val slug = track.tracking_url.split("/")[4] val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon() 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 { class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
private val json: Json by injectLazy() private val json: Json by injectLazy()
@Volatile
private var oauth: HKOAuth? = hikka.loadOAuth() private var oauth: HKOAuth? = hikka.loadOAuth()
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() 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()) { if (currAuth.isExpired()) {
val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken)) currAuth = refreshToken(chain, currAuth)
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))
} }
val authRequest = originalRequest.newBuilder() val authRequest = originalRequest.newBuilder()
@@ -47,6 +31,30 @@ class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
return chain.proceed(authRequest) 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?) { fun setAuth(oauth: HKOAuth?) {
this.oauth = oauth this.oauth = oauth
hikka.saveOAuth(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.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Dns import okhttp3.Dns
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
@@ -145,7 +146,7 @@ class KavitaApi(
return 0.0 return 0.0
} }
suspend fun getTrackSearch(url: String): TrackSearch = withIOContext { suspend fun getTrackSearch(url: String): TrackSearch = withContext(Dispatchers.IO) {
try { try {
val seriesDto: SeriesDto = with(json) { val seriesDto: SeriesDto = with(json) {
authClient.newCall(GET(url)) authClient.newCall(GET(url))
@@ -182,7 +183,7 @@ class KavitaApi(
authClient.newCall( authClient.newCall(
POST(requestUrl, body = EMPTY_JSON_BODY), POST(requestUrl, body = EMPTY_JSON_BODY),
) )
.awaitSuccess() .awaitSuccess().close()
return getTrackSearch(track.tracking_url) 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.dataOrElse
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import mihon.graphql.kitsu.KitsuAddLibMangaMutation import mihon.graphql.kitsu.KitsuAddLibMangaMutation
import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation
@@ -25,7 +26,6 @@ import mihon.graphql.kitsu.KitsuUpdateLibMangaMutation
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import kotlin.time.Instant import kotlin.time.Instant
@@ -181,7 +181,7 @@ class KitsuApi(
} }
suspend fun login(username: String, password: String): KitsuOAuth { suspend fun login(username: String, password: String): KitsuOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("username", username) .add("username", username)
.add("password", password) .add("password", password)
@@ -15,6 +15,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
*/ */
@Volatile
private var oauth: KitsuOAuth? = kitsu.restoreToken() private var oauth: KitsuOAuth? = kitsu.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response { 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") var currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
val refreshToken = currAuth.refreshToken!! // Refresh access token if expired, once for all the requests that found it expired.
// Refresh access token if expired.
if (currAuth.isExpired()) { 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) { if (response.isSuccessful) {
currAuth = with(json) { with(json) { response.parseAs<KitsuOAuth>() }.also(::newAuth)
response.parseAs<KitsuOAuth>()
}
newAuth(currAuth)
} else { } else {
response.close() 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.GET
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Headers import okhttp3.Headers
@@ -13,7 +15,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -33,7 +34,7 @@ class KomgaApi(
private val json: Json by injectLazy() private val json: Json by injectLazy()
suspend fun getTrackSearch(url: String): TrackSearch = suspend fun getTrackSearch(url: String): TrackSearch =
withIOContext { withContext(Dispatchers.IO) {
try { try {
val track = with(json) { val track = with(json) {
if (url.contains(READLIST_API)) { if (url.contains(READLIST_API)) {
@@ -93,7 +94,7 @@ class KomgaApi(
.put(payload.toRequestBody("application/json".toMediaType())) .put(payload.toRequestBody("application/json".toMediaType()))
.build(), .build(),
) )
.awaitSuccess() .awaitSuccess().close()
return getTrackSearch(track.tracking_url) 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.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
@@ -31,7 +33,6 @@ import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode import java.math.RoundingMode
import java.security.SecureRandom import java.security.SecureRandom
@@ -64,7 +65,7 @@ class MangaBakaApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$LIBRARY_API_URL/${track.remote_id}" val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject { val body = buildJsonObject {
put("is_private", track.private) put("is_private", track.private)
@@ -87,7 +88,7 @@ class MangaBakaApi(
authClient authClient
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) .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 // only returns 201 with the body { "status": 201, "data": true }, so no library ID for us
track track
@@ -95,17 +96,17 @@ class MangaBakaApi(
} }
suspend fun deleteLibManga(track: DomainTrack) { suspend fun deleteLibManga(track: DomainTrack) {
withIOContext { withContext(Dispatchers.IO) {
val url = "$LIBRARY_API_URL/${track.remoteId}" val url = "$LIBRARY_API_URL/${track.remoteId}"
authClient authClient
.newCall(DELETE(url)) .newCall(DELETE(url))
.awaitSuccess() .awaitSuccess().close()
} }
} }
suspend fun findLibManga(track: Track): Track? { suspend fun findLibManga(track: Track): Track? {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
try { try {
val url = "$LIBRARY_API_URL/${track.remote_id}" val url = "$LIBRARY_API_URL/${track.remote_id}"
@@ -141,7 +142,7 @@ class MangaBakaApi(
} }
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$LIBRARY_API_URL/${track.remote_id}" val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject { val body = buildJsonObject {
put("state", track.toApiStatus()) put("state", track.toApiStatus())
@@ -172,14 +173,14 @@ class MangaBakaApi(
authClient authClient
.newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON))) .newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess() .awaitSuccess().close()
track track
} }
} }
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon() val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon()
.appendQueryParameter("q", search) .appendQueryParameter("q", search)
.appendQueryParameter("type_not", "novel") .appendQueryParameter("type_not", "novel")
@@ -213,7 +214,7 @@ class MangaBakaApi(
} }
suspend fun getMangaDetails(id: Int): TrackSearch? { suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$API_BASE_URL/v1/series".toUri().buildUpon() val url = "$API_BASE_URL/v1/series".toUri().buildUpon()
.appendPath(id.toString()) .appendPath(id.toString())
.build() .build()
@@ -235,7 +236,7 @@ class MangaBakaApi(
} }
suspend fun getCurrentUser(): MangaBakaUserProfile { suspend fun getCurrentUser(): MangaBakaUserProfile {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
authClient.newCall(GET("$API_BASE_URL/v1/my/profile")) authClient.newCall(GET("$API_BASE_URL/v1/my/profile"))
.awaitSuccess() .awaitSuccess()
@@ -246,7 +247,7 @@ class MangaBakaApi(
} }
suspend fun getAccessToken(code: String): MangaBakaOAuth { suspend fun getAccessToken(code: String): MangaBakaOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
val formBody = FormBody.Builder() val formBody = FormBody.Builder()
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
.add("code", code) .add("code", code)
@@ -11,6 +11,7 @@ class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor {
private val json: Json by injectLazy() private val json: Json by injectLazy()
@Volatile
private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken() private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response { 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") var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka")
// Refresh once for all the requests that found the token expired
if (currentAuth.isExpired()) { 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) { if (response.isSuccessful) {
currentAuth = with(json) { with(json) { response.parseAs<MangaBakaOAuth>() }.also(::setAuth)
response.parseAs<MangaBakaOAuth>()
}
setAuth(currentAuth)
} else { } else {
response.close() response.close()
latest
}
} }
} }
@@ -17,6 +17,8 @@ import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.addJsonObject
@@ -27,7 +29,6 @@ import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack import tachiyomi.domain.track.model.Track as DomainTrack
@@ -44,11 +45,13 @@ class MangaUpdatesApi(
} }
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> { 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}")) authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
.awaitSuccess() .awaitSuccess()
.parseAs<MUListItem>() .parseAs<MUListItem>()
} }
}
val rating = getSeriesRating(track) val rating = getSeriesRating(track)
@@ -98,7 +101,7 @@ class MangaUpdatesApi(
body = body.toString().toRequestBody(CONTENT_TYPE), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess().close()
updateSeriesRating(track) updateSeriesRating(track)
} }
@@ -113,16 +116,18 @@ class MangaUpdatesApi(
body = body.toString().toRequestBody(CONTENT_TYPE), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess().close()
} }
private suspend fun getSeriesRating(track: Track): MURating? { private suspend fun getSeriesRating(track: Track): MURating? {
return try { return try {
withContext(Dispatchers.IO) {
with(json) { with(json) {
authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating")) authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
.awaitSuccess() .awaitSuccess()
.parseAs<MURating>() .parseAs<MURating>()
} }
}
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@@ -140,12 +145,12 @@ class MangaUpdatesApi(
body = body.toString().toRequestBody(CONTENT_TYPE), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess().close()
} else { } else {
authClient.newCall( authClient.newCall(
DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"), 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( client.newCall(
POST( POST(
url = "$BASE_URL/v1/series/search", url = "$BASE_URL/v1/series/search",
@@ -174,9 +180,10 @@ class MangaUpdatesApi(
.map { it.record } .map { it.record }
} }
} }
}
suspend fun getSeriesDetails(id: Long): MURecord? { suspend fun getSeriesDetails(id: Long): MURecord? {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$BASE_URL/v1/series/$id" val url = "$BASE_URL/v1/series/$id"
with(json) { with(json) {
@@ -197,7 +204,8 @@ class MangaUpdatesApi(
put("username", username) put("username", username)
put("password", password) put("password", password)
} }
return with(json) { return withContext(Dispatchers.IO) {
with(json) {
client.newCall( client.newCall(
PUT( PUT(
url = "$BASE_URL/v1/account/login", url = "$BASE_URL/v1/account/login",
@@ -209,14 +217,17 @@ class MangaUpdatesApi(
.context .context
} }
} }
}
suspend fun getCurrentUser(): MUCurrentUser { suspend fun getCurrentUser(): MUCurrentUser {
return with(json) { return withContext(Dispatchers.IO) {
with(json) {
authClient.newCall(GET("$BASE_URL/v1/account/profile")) authClient.newCall(GET("$BASE_URL/v1/account/profile"))
.awaitSuccess() .awaitSuccess()
.parseAs<MUCurrentUser>() .parseAs<MUCurrentUser>()
} }
} }
}
companion object { companion object {
private const val BASE_URL = "https://api.mangaupdates.com" 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.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil import eu.kanade.tachiyomi.util.PkceUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@@ -41,7 +42,7 @@ class MyAnimeListApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun getAccessToken(authCode: String): MALOAuth { suspend fun getAccessToken(authCode: String): MALOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
.add("code", authCode) .add("code", authCode)
@@ -57,7 +58,7 @@ class MyAnimeListApi(
} }
suspend fun getCurrentUser(): String { suspend fun getCurrentUser(): String {
return withIOContext { return withContext(Dispatchers.IO) {
val request = Request.Builder() val request = Request.Builder()
.url("$BASE_API_URL/users/@me") .url("$BASE_API_URL/users/@me")
.get() .get()
@@ -72,7 +73,7 @@ class MyAnimeListApi(
} }
suspend fun search(query: String): List<TrackSearch> { suspend fun search(query: String): List<TrackSearch> {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$BASE_API_URL/manga".toUri().buildUpon() val url = "$BASE_API_URL/manga".toUri().buildUpon()
// MAL API throws a 400 when the query is over 64 characters... // MAL API throws a 400 when the query is over 64 characters...
.appendQueryParameter("q", query.take(64)) .appendQueryParameter("q", query.take(64))
@@ -91,7 +92,7 @@ class MyAnimeListApi(
} }
suspend fun getMangaDetails(id: Int): TrackSearch { suspend fun getMangaDetails(id: Int): TrackSearch {
return withIOContext { return withContext(Dispatchers.IO) {
val url = "$BASE_API_URL/manga".toUri().buildUpon() val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendPath(id.toString()) .appendPath(id.toString())
.appendQueryParameter("fields", SEARCH_FIELDS) .appendQueryParameter("fields", SEARCH_FIELDS)
@@ -106,7 +107,7 @@ class MyAnimeListApi(
} }
suspend fun updateItem(track: Track): Track { suspend fun updateItem(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val formBodyBuilder = FormBody.Builder() val formBodyBuilder = FormBody.Builder()
.add("status", track.toMyAnimeListStatus() ?: "reading") .add("status", track.toMyAnimeListStatus() ?: "reading")
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString()) .add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
@@ -148,15 +149,15 @@ class MyAnimeListApi(
} }
suspend fun deleteItem(track: DomainTrack) { suspend fun deleteItem(track: DomainTrack) {
withIOContext { withContext(Dispatchers.IO) {
authClient authClient
.newCall(DELETE(mangaUrl(track.remoteId).toString())) .newCall(DELETE(mangaUrl(track.remoteId).toString()))
.awaitSuccess() .awaitSuccess().close()
} }
} }
suspend fun findListItem(track: Track): Track? { suspend fun findListItem(track: Track): Track? {
return withIOContext { return withContext(Dispatchers.IO) {
val uri = "$BASE_API_URL/manga".toUri().buildUpon() val uri = "$BASE_API_URL/manga".toUri().buildUpon()
.appendPath(track.remote_id.toString()) .appendPath(track.remote_id.toString())
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") .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> { suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
return withIOContext { return withContext(Dispatchers.IO) {
val myListSearchResult = getListPage(offset) val myListSearchResult = getListPage(offset)
val matches = myListSearchResult.data val matches = myListSearchResult.data
@@ -191,7 +192,7 @@ class MyAnimeListApi(
} }
private suspend fun getListPage(offset: Int): MALSearchResult { private suspend fun getListPage(offset: Int): MALSearchResult {
return withIOContext { return withContext(Dispatchers.IO) {
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
.appendQueryParameter("fields", SEARCH_FIELDS) .appendQueryParameter("fields", SEARCH_FIELDS)
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString()) .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.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
@@ -28,7 +29,6 @@ import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack import tachiyomi.domain.track.model.Track as DomainTrack
@@ -53,7 +53,7 @@ class ShikimoriApi(
} }
suspend fun addLibManga(track: Track, userId: String): Track { suspend fun addLibManga(track: Track, userId: String): Track {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
val payload = buildJsonObject { val payload = buildJsonObject {
putJsonObject("user_rate") { putJsonObject("user_rate") {
@@ -82,7 +82,7 @@ class ShikimoriApi(
} }
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withContext(Dispatchers.IO) {
val payload = buildJsonObject { val payload = buildJsonObject {
putJsonObject("user_rate") { putJsonObject("user_rate") {
put("chapters", track.last_chapter_read.toInt()) put("chapters", track.last_chapter_read.toInt())
@@ -109,10 +109,10 @@ class ShikimoriApi(
} }
suspend fun deleteLibManga(track: DomainTrack) { suspend fun deleteLibManga(track: DomainTrack) {
withIOContext { withContext(Dispatchers.IO) {
authClient authClient
.newCall(DELETE("$API_URL/v2/user_rates/${track.libraryId}")) .newCall(DELETE("$API_URL/v2/user_rates/${track.libraryId}"))
.awaitSuccess() .awaitSuccess().close()
} }
} }
@@ -192,7 +192,7 @@ class ShikimoriApi(
} }
suspend fun accessToken(code: String): SMOAuth { suspend fun accessToken(code: String): SMOAuth {
return withIOContext { return withContext(Dispatchers.IO) {
with(json) { with(json) {
client.newCall(accessTokenRequest(code)) client.newCall(accessTokenRequest(code))
.awaitSuccess() .awaitSuccess()
@@ -15,6 +15,7 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
*/ */
@Volatile
private var oauth: SMOAuth? = shikimori.restoreToken() private var oauth: SMOAuth? = shikimori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response { 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") 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()) { 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) { if (response.isSuccessful) {
currAuth = with(json) { with(json) { response.parseAs<SMOAuth>() }.also(::newAuth)
response.parseAs<SMOAuth>()
}
newAuth(currAuth)
} else { } else {
response.close() response.close()
latest
}
} }
} }
// Add the authorization header to the original request. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() 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})") .header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build() .build()
@@ -106,7 +106,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong() private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong()
private fun getPrefTrackerDelete(): Boolean { private suspend fun getPrefTrackerDelete(): Boolean {
val preferences = api.sourcePreferences() val preferences = api.sourcePreferences()
return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT) 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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.dataOrElse import eu.kanade.tachiyomi.network.dataOrElse
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import mihon.graphql.suwayomi.SuwayomiGetMangaQuery import mihon.graphql.suwayomi.SuwayomiGetMangaQuery
import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery
import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation
import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation
import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation
import okhttp3.OkHttpClient
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import java.security.MessageDigest import java.security.MessageDigest
@@ -25,28 +22,30 @@ class SuwayomiApi(
private val trackerId: Long, private val trackerId: Long,
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
) { ) {
// Blocking is fine here: these are only touched from OkHttp and tracker threads. @Volatile
private val source: Source by lazy { runBlocking { sourceManager.get(sourceId)!! } } private var connection: Connection? = null
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" }
private val graphQlClient by lazy { private suspend fun connection(): Connection {
ApolloClient.Builder() connection?.let { return it }
.serverUrl(apiUrl) val source = sourceManager.get(sourceId) as HttpSource
.okHttpClient(client) val baseUrl = source.baseUrl.trimEnd('/')
val graphQlClient = ApolloClient.Builder()
.serverUrl("$baseUrl/api/graphql")
.okHttpClient(source.client)
.dispatcher(Dispatchers.IO) .dispatcher(Dispatchers.IO)
// required to log the error body in dataOrElse, which also properly closes it // required to log the error body in dataOrElse, which also properly closes it
.httpExposeErrorBody(true) .httpExposeErrorBody(true)
.build() .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? { suspend fun getTrackSearch(mangaId: Long): TrackSearch? {
return graphQlClient val connection = connection()
return connection.graphQlClient
.query( .query(
SuwayomiGetMangaQuery(mangaId = mangaId.toInt()), SuwayomiGetMangaQuery(mangaId = mangaId.toInt()),
) )
@@ -55,11 +54,12 @@ class SuwayomiApi(
errorLog = "Suwayomi: Failed to find manga in library", errorLog = "Suwayomi: Failed to find manga in library",
default = { null }, default = { null },
) { ) {
it.manga.mangaFragment.toTrackSearch(trackerId, baseUrl) it.manga.mangaFragment.toTrackSearch(trackerId, connection.baseUrl)
} }
} }
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? { suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? {
val graphQlClient = connection().graphQlClient
val mangaId = track.remote_id val mangaId = track.remote_id
val chaptersToMark = graphQlClient val chaptersToMark = graphQlClient
@@ -114,4 +114,10 @@ class SuwayomiApi(
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) 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 (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.BuildConfig
import eu.kanade.tachiyomi.util.system.isFossBuildType import eu.kanade.tachiyomi.util.system.isFossBuildType
import eu.kanade.tachiyomi.util.system.isNightlyBuildType 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 import tachiyomi.domain.release.interactor.GetApplicationRelease
@Inject @Inject
@@ -18,7 +19,7 @@ class AppUpdateChecker(
// return GetApplicationRelease.Result.OsTooOld // return GetApplicationRelease.Result.OsTooOld
// } // }
return withIOContext { return withContext(Dispatchers.IO) {
val result = getApplicationRelease.await( val result = getApplicationRelease.await(
GetApplicationRelease.Arguments( GetApplicationRelease.Arguments(
isFossBuildType, isFossBuildType,
@@ -31,17 +31,19 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository 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.core.common.util.system.logcat
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
/** /**
* The manager of extensions installed as another apk which extend the available sources. It handles * 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 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 @Volatile
private var stores = emptyList<ExtensionStore>() 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 * again, so one can move between loaded and not loaded in either direction, while extensions
* that still pass keep the instances they already had. * that still pass keep the instances they already had.
*/ */
private suspend fun loadExtensions() { private suspend fun loadExtensions() = loadMutex.withLock {
try { try {
val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value) val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value)
@@ -197,7 +202,7 @@ class ExtensionManager(
fetchExtensions() fetchExtensions()
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
withUIContext { context.toast(MR.strings.extension_api_error) } withContext(Dispatchers.Main) { context.toast(MR.strings.extension_api_error) }
return return
} }
@@ -229,7 +234,7 @@ class ExtensionManager(
} }
private suspend fun fetchExtensions(): List<Extension.Available> { private suspend fun fetchExtensions(): List<Extension.Available> {
return withIOContext { extensionStoreRepository.fetchExtensions() } return extensionStoreRepository.fetchExtensions()
} }
private fun setAvailableExtensions(extensions: List<Extension.Available>) { private fun setAvailableExtensions(extensions: List<Extension.Available>) {
@@ -340,7 +345,7 @@ class ExtensionManager(
* @param extension The extension to be registered. * @param extension The extension to be registered.
*/ */
private fun registerExtension(extension: Extension.Loaded) { 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. * @param pkgName The package name of the uninstalled application.
*/ */
private fun unregisterExtension(pkgName: String) { private fun unregisterExtension(pkgName: String) {
loadedExtensionMapFlow.value -= pkgName loadedExtensionMapFlow.update { it - pkgName }
notLoadedExtensionMapFlow.value -= pkgName notLoadedExtensionMapFlow.update { it - pkgName }
} }
/** /**
@@ -359,19 +364,19 @@ class ExtensionManager(
*/ */
private inner class InstallationListener : ExtensionInstallReceiver.Listener { private inner class InstallationListener : ExtensionInstallReceiver.Listener {
override fun onExtensionLoaded(extension: Extension.Loaded) { override suspend fun onExtensionLoaded(extension: Extension.Loaded) = loadMutex.withLock {
registerExtension(extension) registerExtension(extension)
notLoadedExtensionMapFlow.value -= extension.pkgName notLoadedExtensionMapFlow.update { it - extension.pkgName }
refreshStatuses() refreshStatuses()
} }
override fun onExtensionNotLoaded(extension: Extension.NotLoaded) { override suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded) = loadMutex.withLock {
loadedExtensionMapFlow.value -= extension.pkgName loadedExtensionMapFlow.update { it - extension.pkgName }
notLoadedExtensionMapFlow.value += extension notLoadedExtensionMapFlow.update { it + extension }
refreshStatuses() refreshStatuses()
} }
override fun onPackageUninstalled(pkgName: String) { override suspend fun onPackageUninstalled(pkgName: String) = loadMutex.withLock {
ExtensionLoader.uninstallPrivateExtension(context, pkgName) ExtensionLoader.uninstallPrivateExtension(context, pkgName)
unregisterExtension(pkgName) unregisterExtension(pkgName)
updatePendingUpdatesCount() updatePendingUpdatesCount()
@@ -385,7 +390,8 @@ class ExtensionManager(
private fun refreshStatuses() { private fun refreshStatuses() {
val available = availableExtensionListFlow.value val available = availableExtensionListFlow.value
if (available.isNotEmpty()) { if (available.isNotEmpty()) {
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) -> loadedExtensionMapFlow.update { current ->
current.mapValues { (_, extension) ->
val listing = extension.findListing(available) val listing = extension.findListing(available)
extension.copy( extension.copy(
hasUpdate = extension.findUpdate(available) != null, hasUpdate = extension.findUpdate(available) != null,
@@ -393,13 +399,16 @@ class ExtensionManager(
store = if (stores.isEmpty()) extension.store else extension.pickStore(), store = if (stores.isEmpty()) extension.store else extension.pickStore(),
) )
} }
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) -> }
notLoadedExtensionMapFlow.update { current ->
current.mapValues { (_, extension) ->
extension.copy( extension.copy(
hasUpdate = extension.findUpdate(available) != null, hasUpdate = extension.findUpdate(available) != null,
store = if (stores.isEmpty()) extension.store else extension.pickStore(), store = if (stores.isEmpty()) extension.store else extension.pickStore(),
) )
} }
} }
}
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }
@@ -15,6 +15,11 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
import eu.kanade.tachiyomi.util.system.getUriSize 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 logcat.LogPriority
import tachiyomi.core.common.util.system.logcat 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 packageInstaller = service.packageManager.packageInstaller
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val packageActionReceiver = object : BroadcastReceiver() { private val packageActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) 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 private var activeSession: Pair<Entry, Int>? = null
// Always ready // Always ready
@@ -76,6 +84,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
override fun processEntry(entry: Entry) { override fun processEntry(entry: Entry) {
super.processEntry(entry) super.processEntry(entry)
activeSession = null activeSession = null
scope.launch {
try { try {
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -107,9 +116,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
activeSession?.let { (_, sessionId) -> 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() { override fun onDestroy() {
service.unregisterReceiver(packageActionReceiver) service.unregisterReceiver(packageActionReceiver)
scope.cancel()
super.onDestroy() super.onDestroy()
} }
@@ -19,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.app.shizuku.IShellInterface import mihon.app.shizuku.IShellInterface
import mihon.app.shizuku.ShellInterface import mihon.app.shizuku.ShellInterface
@@ -117,6 +118,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
override fun processEntry(entry: Entry) { override fun processEntry(entry: Entry) {
super.processEntry(entry) super.processEntry(entry)
scope.launch {
try { try {
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use { service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
shellInterface?.install(it, statusIntent.intentSender) shellInterface?.install(it, statusIntent.intentSender)
@@ -127,6 +129,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
continueQueue(InstallStep.Error) continueQueue(InstallStep.Error)
} }
} }
}
// Don't cancel if entry is already started installing // Don't cancel if entry is already started installing
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
@@ -8,7 +8,9 @@ import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -25,6 +27,22 @@ internal class ExtensionInstallReceiver(
private val scope: CoroutineScope, private val scope: CoroutineScope,
) : BroadcastReceiver() { ) : 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) { fun register(context: Context) {
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
} }
@@ -49,31 +67,14 @@ internal class ExtensionInstallReceiver(
when (intent.action) { when (intent.action) {
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) }
scope.launch {
when (val extension = getExtensionFromIntent(context, intent)) {
is Extension.Loaded -> listener.onExtensionLoaded(extension)
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
null -> {}
}
}
} }
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
scope.launch { getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) }
when (val extension = getExtensionFromIntent(context, intent)) {
is Extension.Loaded -> listener.onExtensionLoaded(extension)
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
null -> {}
}
}
} }
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> { Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
getPackageNameFromIntent(intent)?.let { events.trySend(Event.Removed(it)) }
val pkgName = getPackageNameFromIntent(intent)
if (pkgName != null) {
listener.onPackageUninstalled(pkgName)
}
} }
} }
} }
@@ -87,19 +88,20 @@ internal class ExtensionInstallReceiver(
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
} }
/** private suspend fun handle(event: Event) {
* Returns the extension triggered by the given intent. when (event) {
* is Event.Installed -> when (
* @param context The application context. val extension = ExtensionLoader.loadExtensionFromPkgName(
* @param intent The intent containing the package name of the extension. event.context,
*/ event.pkgName,
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): Extension.Installed? { )
val pkgName = getPackageNameFromIntent(intent) ) {
if (pkgName == null) { is Extension.Loaded -> listener.onExtensionLoaded(extension)
logcat(LogPriority.WARN) { "Package name not found" } is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
return null 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. * Listener that receives extension installation events.
*/ */
interface Listener { interface Listener {
fun onExtensionLoaded(extension: Extension.Loaded) suspend fun onExtensionLoaded(extension: Extension.Loaded)
fun onExtensionNotLoaded(extension: Extension.NotLoaded) suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded)
fun onPackageUninstalled(pkgName: String) 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 { 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.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap
/** /**
* The installer which installs, updates and uninstalls the extensions. * The installer which installs, updates and uninstalls the extensions.
@@ -40,11 +45,11 @@ class ExtensionInstaller(
networkHelper: NetworkHelper, networkHelper: NetworkHelper,
) { ) {
private val activeJobs = mutableMapOf<String, Job>() private val activeJobs = ConcurrentHashMap<String, Job>()
private val activeSteps = mutableMapOf<Long, MutableStateFlow<InstallStep>>() private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
private val extensionInstaller = basePreferences.extensionInstaller 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 * Adds the given extension to the downloads queue and returns an observable containing its
@@ -68,16 +73,19 @@ class ExtensionInstaller(
try { try {
step.value = InstallStep.Downloading step.value = InstallStep.Downloading
val request = Request.Builder().url(extension.apkUrl).build() val request = Request.Builder().url(extension.apkUrl).build()
val response = httpClient.newCall(request).execute() httpClient.newCall(request).await().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw Exception("Failed to download extension") throw Exception("Failed to download extension")
} }
// Interruptible so cancelInstall stops the body read too
runInterruptible {
response.body.byteStream().use { input -> response.body.byteStream().use { input ->
tmpFile.outputStream().use { output -> tmpFile.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
}
}
// The store's index names the apk, but nothing ties what's served there to the store's key // 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) 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}") throw Exception("Extension isn't signed with the key of ${extension.store.name}")
} }
ensureActive()
step.value = InstallStep.Installing step.value = InstallStep.Installing
installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled) installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled)
} catch (e: Exception) { } catch (e: Exception) {
if (e is InterruptedException) { if (e is CancellationException) throw e
// Canceled
} else {
logcat(LogPriority.INFO, e) logcat(LogPriority.INFO, e)
step.value = InstallStep.Error step.value = InstallStep.Error
} }
} }
}
activeJobs[extension.pkgName] = job activeJobs[extension.pkgName] = job
@@ -12,14 +12,15 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.data.dalvik.DelegateLastClassLoaderCompat import mihon.data.dalvik.DelegateLastClassLoaderCompat
import mihon.domain.extension.model.ContentWarning import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
@@ -147,15 +148,16 @@ internal object ExtensionLoader {
} }
?.filter { isPackageAnExtension(it) } ?.filter { isPackageAnExtension(it) }
?.map { ExtensionInfo(packageInfo = it, isShared = false) } ?.map { ExtensionInfo(packageInfo = it, isShared = false) }
?: emptySequence() ?.toList()
.orEmpty()
val privateExtPkgsByName = privateExtPkgs.groupBy { it.packageInfo.packageName }
val extPkgs = (sharedExtPkgs + privateExtPkgs) val extPkgs = (sharedExtPkgs + privateExtPkgs)
// Remove duplicates. Shared takes priority than private by default // Remove duplicates. Shared takes priority than private by default
.distinctBy { it.packageInfo.packageName } .distinctBy { it.packageInfo.packageName }
// Compare version number // Compare version number
.mapNotNull { sharedPkg -> .mapNotNull { sharedPkg ->
val privatePkg = privateExtPkgs val privatePkg = privateExtPkgsByName[sharedPkg.packageInfo.packageName]?.singleOrNull()
.singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
selectExtensionPackage(sharedPkg, privatePkg) selectExtensionPackage(sharedPkg, privatePkg)
} }
.toList() .toList()
@@ -163,7 +165,7 @@ internal object ExtensionLoader {
if (extPkgs.isEmpty()) return emptyList() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return withIOContext { return withContext(Dispatchers.IO) {
extPkgs extPkgs
.map { .map {
async { async {
@@ -11,6 +11,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey
import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.extension.interactor.GetExtensionLanguages
import eu.kanade.domain.source.interactor.ToggleLanguage import eu.kanade.domain.source.interactor.ToggleLanguage
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority import logcat.LogPriority
@@ -49,6 +51,7 @@ class ExtensionFilterViewModel(
logcat(LogPriority.ERROR, throwable) logcat(LogPriority.ERROR, throwable)
_events.send(ExtensionFilterEvent.FailedFetchingLanguages) _events.send(ExtensionFilterEvent.FailedFetchingLanguages)
} }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), ExtensionFilterState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), ExtensionFilterState.Loading)
fun toggle(language: String) { fun toggle(language: String) {
@@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import kotlin.time.Duration.Companion.seconds 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) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
@@ -127,7 +126,7 @@ class ExtensionsViewModel(
init { init {
viewModelScope.launchIO { findAvailableExtensions() } viewModelScope.launch(Dispatchers.IO) { findAvailableExtensions() }
} }
fun searchQueryPredicate(query: String): (Extension) -> Boolean { fun searchQueryPredicate(query: String): (Extension) -> Boolean {
@@ -165,7 +164,7 @@ class ExtensionsViewModel(
} }
fun updateAllExtensions() { fun updateAllExtensions() {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
state.value.items.values.flatten() state.value.items.values.flatten()
.map { it.extension } .map { it.extension }
.filterIsInstance<Extension.Installed>() .filterIsInstance<Extension.Installed>()
@@ -175,13 +174,13 @@ class ExtensionsViewModel(
} }
fun installExtension(extension: Extension.Available) { fun installExtension(extension: Extension.Available) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
extensionManager.installExtension(extension).collectToInstallUpdate(extension) extensionManager.installExtension(extension).collectToInstallUpdate(extension)
} }
} }
fun updateExtension(extension: Extension.Installed) { fun updateExtension(extension: Extension.Installed) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
extensionManager.updateExtension(extension).collectToInstallUpdate(extension) extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
} }
} }
@@ -211,7 +210,7 @@ class ExtensionsViewModel(
} }
fun findAvailableExtensions() { fun findAvailableExtensions() {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
isRefreshing.update { true } isRefreshing.update { true }
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
@@ -39,7 +39,9 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mihon.app.di.appGraph import mihon.app.di.appGraph
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
@@ -145,7 +147,8 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
if (source is ConfigurableSource) { 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 preferenceManager.preferenceDataStore = dataStore
source.setupPreferenceScreen(sourceScreen) source.setupPreferenceScreen(sourceScreen)
@@ -71,7 +71,7 @@ class MigrateMangaViewModel(
) { titleList, selection -> ) { titleList, selection ->
State(source = source.await(), selection = selection, titleList = titleList) State(source = source.await(), selection = selection, titleList = titleList)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
fun toggleSelection(item: Manga) { 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.ToggleLanguage
import eu.kanade.domain.source.interactor.ToggleSource import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import java.util.SortedMap import java.util.SortedMap
@@ -44,6 +46,7 @@ class SourcesFilterViewModel(
) )
} }
.catch<State> { throwable -> emit(State.Error(throwable = throwable)) } .catch<State> { throwable -> emit(State.Error(throwable = throwable)) }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
fun toggleSource(source: Source) { 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.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.FilterList import mihon.icons.materialsymbols.rounded.FilterList
@@ -55,7 +57,6 @@ import mihon.icons.materialsymbols.rounded.Verified
import mihon.icons.materialsymbols.roundedfilled.Favorite import mihon.icons.materialsymbols.roundedfilled.Favorite
import mihon.presentation.core.util.collectAsLazyPagingItems import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@@ -224,7 +225,7 @@ data class BrowseSourceScreen(
onLocalSourceHelpClick = onHelpClick, onLocalSourceHelpClick = onHelpClick,
onMangaClick = { navigator.push((MangaScreen(it.id, true))) }, onMangaClick = { navigator.push((MangaScreen(it.id, true))) },
onMangaLongClick = { manga -> onMangaLongClick = { manga ->
scope.launchIO { scope.launch(Dispatchers.IO) {
val duplicates = viewModel.getDuplicateLibraryManga(manga) val duplicates = viewModel.getDuplicateLibraryManga(manga)
when { when {
manga.favorite -> viewModel.setDialog(BrowseSourceViewModel.Dialog.RemoveManga(manga)) 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.Source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -41,9 +43,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.mapAsCheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@@ -64,6 +66,7 @@ import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
class BrowseSourceViewModel( class BrowseSourceViewModel(
@Assisted private val sourceId: Long, @Assisted private val sourceId: Long,
@Assisted listingQuery: String?, @Assisted listingQuery: String?,
@AppCoroutineScope private val appScope: CoroutineScope,
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
sourcePreferences: SourcePreferences, sourcePreferences: SourcePreferences,
private val libraryPreferences: LibraryPreferences, private val libraryPreferences: LibraryPreferences,
@@ -94,7 +97,7 @@ class BrowseSourceViewModel(
private val source: Source? get() = state.value.source private val source: Source? get() = state.value.source
init { init {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val source = sourceManager.getOrStub(sourceId) val source = sourceManager.getOrStub(sourceId)
state.update { state.update {
@@ -234,7 +237,7 @@ class BrowseSourceViewModel(
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun changeMangaFavorite(manga: Manga) { fun changeMangaFavorite(manga: Manga) {
viewModelScope.launch { appScope.launch {
val update = if (manga.favorite) { val update = if (manga.favorite) {
val coverLastModified = manga.removeCovers(coverCache).coverLastModified val coverLastModified = manga.removeCovers(coverCache).coverLastModified
MangaUpdate(manga.id) { MangaUpdate(manga.id) {
@@ -307,7 +310,7 @@ class BrowseSourceViewModel(
} }
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) { fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
setMangaCategories.await( setMangaCategories.await(
mangaId = manga.id, mangaId = manga.id,
categoryIds = categoryIds.toList(), categoryIds = categoryIds.toList(),
@@ -22,7 +22,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mihon.domain.manga.model.toDomainManga import mihon.domain.manga.model.toDomainManga
import tachiyomi.core.common.preference.toggle import tachiyomi.core.common.preference.toggle
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -139,7 +138,7 @@ abstract class SearchViewModel(
searchJob?.cancel() searchJob?.cancel()
searchJob = viewModelScope.launchIO { searchJob = viewModelScope.launch(Dispatchers.IO) {
val sources = getSelectedSources() val sources = getSelectedSources()
// Reuse previous results if possible // Reuse previous results if possible
@@ -205,8 +204,8 @@ abstract class SearchViewModel(
} }
fun setMigrateDialog(currentId: Long, target: Manga) { fun setMigrateDialog(currentId: Long, target: Manga) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val current = getManga.await(currentId) ?: return@launchIO val current = getManga.await(currentId) ?: return@launch
state.update { it.copy(dialog = Dialog.Migrate(target, current)) } 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.Inject
import dev.zacsweers.metro.binding import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.viewmodel.ViewModelKey import dev.zacsweers.metrox.viewmodel.ViewModelKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mihon.core.metro.AppCoroutineScope
import tachiyomi.domain.category.interactor.CreateCategoryWithName import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.DeleteCategory import tachiyomi.domain.category.interactor.DeleteCategory
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
@@ -32,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>()) @ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class CategoryViewModel( class CategoryViewModel(
@AppCoroutineScope private val appScope: CoroutineScope,
private val getCategories: GetCategories, private val getCategories: GetCategories,
private val createCategoryWithName: CreateCategoryWithName, private val createCategoryWithName: CreateCategoryWithName,
private val deleteCategory: DeleteCategory, private val deleteCategory: DeleteCategory,
@@ -66,7 +70,8 @@ class CategoryViewModel(
fun deleteCategory(categoryId: Long) { fun deleteCategory(categoryId: Long) {
viewModelScope.launch { 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) is DeleteCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
else -> {} else -> {}
} }
@@ -14,12 +14,13 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.ResolvableSource import eu.kanade.tachiyomi.source.online.ResolvableSource
import eu.kanade.tachiyomi.source.online.UriType import eu.kanade.tachiyomi.source.online.UriType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mihon.domain.manga.model.toDomainManga import mihon.domain.manga.model.toDomainManga
import mihon.domain.source.interactor.UpdateMangaFromRemote import mihon.domain.source.interactor.UpdateMangaFromRemote
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
@@ -46,7 +47,7 @@ class DeepLinkViewModel(
} }
init { init {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val source = sourceManager.getAll() val source = sourceManager.getAll()
.filterIsInstance<ResolvableSource>() .filterIsInstance<ResolvableSource>()
.firstOrNull { it.getUriType(query) != UriType.Unknown } .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.components.NestedMenuItem
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.databinding.DownloadListBinding import eu.kanade.tachiyomi.databinding.DownloadListBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Sort import mihon.icons.materialsymbols.automirroredrounded.Sort
import mihon.icons.materialsymbols.roundedfilled.Pause import mihon.icons.materialsymbols.roundedfilled.Pause
import mihon.icons.materialsymbols.roundedfilled.PlayArrow import mihon.icons.materialsymbols.roundedfilled.PlayArrow
import tachiyomi.core.common.util.lang.launchUI
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.Pill import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@@ -258,11 +259,11 @@ object DownloadQueueScreen : Screen() {
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true) ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
scope.launchUI { scope.launch(Dispatchers.Main) {
viewModel.getDownloadStatusFlow() viewModel.getDownloadStatusFlow()
.collect(viewModel::onStatusChange) .collect(viewModel::onStatusChange)
} }
scope.launchUI { scope.launch(Dispatchers.Main) {
viewModel.getDownloadProgressFlow() viewModel.getDownloadProgressFlow()
.collect(viewModel::onUpdateDownloadedPages) .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.data.download.model.Download
import eu.kanade.tachiyomi.databinding.DownloadListBinding import eu.kanade.tachiyomi.databinding.DownloadListBinding
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -44,6 +46,7 @@ class DownloadQueueViewModel(
} }
} }
} }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
lateinit var controllerBinding: DownloadListBinding lateinit var controllerBinding: DownloadListBinding
@@ -149,19 +152,19 @@ class DownloadQueueViewModel(
} }
fun pauseDownloads() { fun pauseDownloads() {
downloadManager.pauseDownloads() viewModelScope.launch { downloadManager.pauseDownloads() }
} }
fun clearQueue() { fun clearQueue() {
downloadManager.clearQueue() viewModelScope.launch { downloadManager.clearQueue() }
} }
fun reorder(downloads: List<Download>) { fun reorder(downloads: List<Download>) {
downloadManager.reorderQueue(downloads) viewModelScope.launch { downloadManager.reorderQueue(downloads) }
} }
fun cancel(downloads: List<Download>) { fun cancel(downloads: List<Download>) {
downloadManager.cancelQueuedDownloads(downloads) viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
} }
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) { 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.domain.track.interactor.AddTracks
import eu.kanade.presentation.history.HistoryUiModel import eu.kanade.presentation.history.HistoryUiModel
import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,10 +33,9 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.mapAsCheckboxState 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.core.common.util.system.logcat
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
@@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>()) @ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class HistoryViewModel( class HistoryViewModel(
@AppCoroutineScope private val appScope: CoroutineScope,
private val addTracks: AddTracks, private val addTracks: AddTracks,
private val getCategories: GetCategories, private val getCategories: GetCategories,
private val getDuplicateLibraryManga: GetDuplicateLibraryManga, private val getDuplicateLibraryManga: GetDuplicateLibraryManga,
@@ -88,7 +89,7 @@ class HistoryViewModel(
_events.send(Event.InternalError) _events.send(Event.InternalError)
} }
.map { it.toHistoryUiModels() } .map { it.toHistoryUiModels() }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.Default)
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
@@ -115,11 +116,11 @@ class HistoryViewModel(
} }
suspend fun getNextChapter(): Chapter? { suspend fun getNextChapter(): Chapter? {
return withIOContext { getNextChapters.await(onlyUnread = false).firstOrNull() } return getNextChapters.await(onlyUnread = false).firstOrNull()
} }
fun getNextChapterForManga(mangaId: Long, chapterId: Long) { fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
viewModelScope.launchIO { viewModelScope.launch {
sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false)) sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false))
} }
} }
@@ -130,21 +131,21 @@ class HistoryViewModel(
} }
fun removeFromHistory(history: HistoryWithRelations) { fun removeFromHistory(history: HistoryWithRelations) {
viewModelScope.launchIO { viewModelScope.launch {
removeHistory.await(history) removeHistory.await(history)
} }
} }
fun removeAllFromHistory(mangaId: Long) { fun removeAllFromHistory(mangaId: Long) {
viewModelScope.launchIO { viewModelScope.launch {
removeHistory.await(mangaId) removeHistory.await(mangaId)
} }
} }
fun removeAllHistory() { fun removeAllHistory() {
viewModelScope.launchIO { viewModelScope.launch {
val result = removeHistory.awaitAll() val result = removeHistory.awaitAll()
if (!result) return@launchIO if (!result) return@launch
_events.send(Event.HistoryCleared) _events.send(Event.HistoryCleared)
} }
} }
@@ -172,7 +173,7 @@ class HistoryViewModel(
} }
private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) { private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) {
viewModelScope.launchIO { viewModelScope.launch {
setMangaCategories.await(mangaId, categoryIds) setMangaCategories.await(mangaId, categoryIds)
} }
} }
@@ -181,7 +182,7 @@ class HistoryViewModel(
moveMangaToCategory(manga.id, categories) moveMangaToCategory(manga.id, categories)
if (manga.favorite) return if (manga.favorite) return
viewModelScope.launchIO { viewModelScope.launch {
updateManga.awaitUpdateFavorite(manga.id, true) updateManga.awaitUpdateFavorite(manga.id, true)
} }
} }
@@ -192,13 +193,13 @@ class HistoryViewModel(
} }
fun addFavorite(mangaId: Long) { fun addFavorite(mangaId: Long) {
viewModelScope.launchIO { viewModelScope.launch {
val manga = getManga.await(mangaId) ?: return@launchIO val manga = getManga.await(mangaId) ?: return@launch
val duplicates = getDuplicateLibraryManga(manga) val duplicates = getDuplicateLibraryManga(manga)
if (duplicates.isNotEmpty()) { if (duplicates.isNotEmpty()) {
dialog.update { Dialog.DuplicateManga(manga, duplicates) } dialog.update { Dialog.DuplicateManga(manga, duplicates) }
return@launchIO return@launch
} }
addFavorite(manga) addFavorite(manga)
@@ -206,7 +207,7 @@ class HistoryViewModel(
} }
fun addFavorite(manga: Manga) { fun addFavorite(manga: Manga) {
viewModelScope.launchIO { viewModelScope.launch {
// Move to default category if applicable // Move to default category if applicable
val categories = getCategories() val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory.get().toLong() val defaultCategoryId = libraryPreferences.defaultCategory.get().toLong()
@@ -216,14 +217,14 @@ class HistoryViewModel(
// Default category set // Default category set
defaultCategory != null -> { defaultCategory != null -> {
val result = updateManga.awaitUpdateFavorite(manga.id, true) val result = updateManga.awaitUpdateFavorite(manga.id, true)
if (!result) return@launchIO if (!result) return@launch
moveMangaToCategory(manga.id, defaultCategory) moveMangaToCategory(manga.id, defaultCategory)
} }
// Automatic 'Default' or no categories // Automatic 'Default' or no categories
defaultCategoryId == 0L || categories.isEmpty() -> { defaultCategoryId == 0L || categories.isEmpty() -> {
val result = updateManga.awaitUpdateFavorite(manga.id, true) val result = updateManga.awaitUpdateFavorite(manga.id, true)
if (!result) return@launchIO if (!result) return@launch
moveMangaToCategory(manga.id, null) moveMangaToCategory(manga.id, null)
} }
@@ -232,7 +233,7 @@ class HistoryViewModel(
} }
// Sync with tracking services if applicable // 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 eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.category.interactor.SetDisplayMode import tachiyomi.domain.category.interactor.SetDisplayMode
import tachiyomi.domain.category.interactor.SetSortModeForCategory import tachiyomi.domain.category.interactor.SetSortModeForCategory
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@@ -55,7 +55,7 @@ class LibrarySettingsViewModel(
} }
fun setSort(category: Category?, mode: LibrarySort.Type, direction: LibrarySort.Direction) { fun setSort(category: Category?, mode: LibrarySort.Type, direction: LibrarySort.Direction) {
viewModelScope.launchIO { viewModelScope.launch {
setSortModeForCategory.await(category, mode, direction) setSortModeForCategory.await(category, mode, direction)
} }
} }
@@ -50,7 +50,6 @@ import mihon.feature.migration.config.MigrationConfigScreen
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.Help import mihon.icons.materialsymbols.automirroredrounded.Help
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -93,7 +92,7 @@ data object LibraryTab : Tab {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val onClickRefresh: (Category?) -> Boolean = { category -> val onClickRefresh: suspend (Category?) -> Boolean = { category ->
val started = LibraryUpdateWorker.startNow(context.workManager, category) val started = LibraryUpdateWorker.startNow(context.workManager, category)
scope.launch { scope.launch {
val msgRes = when { val msgRes = when {
@@ -121,8 +120,8 @@ data object LibraryTab : Tab {
onClickSelectAll = viewModel::selectAll, onClickSelectAll = viewModel::selectAll,
onClickInvertSelection = viewModel::invertSelection, onClickInvertSelection = viewModel::invertSelection,
onClickFilter = viewModel::showSettingsDialog, onClickFilter = viewModel::showSettingsDialog,
onClickRefresh = { onClickRefresh(state.activeCategory) }, onClickRefresh = { scope.launch { onClickRefresh(state.activeCategory) } },
onClickGlobalUpdate = { onClickRefresh(null) }, onClickGlobalUpdate = { scope.launch { onClickRefresh(null) } },
onClickOpenRandomManga = { onClickOpenRandomManga = {
scope.launch { scope.launch {
val randomItem = viewModel.getRandomLibraryItemForCurrentCategory() val randomItem = viewModel.getRandomLibraryItemForCurrentCategory()
@@ -189,7 +188,7 @@ data object LibraryTab : Tab {
onChangeCurrentPage = viewModel::updateActiveCategoryIndex, onChangeCurrentPage = viewModel::updateActiveCategoryIndex,
onClickManga = { navigator.push(MangaScreen(it)) }, onClickManga = { navigator.push(MangaScreen(it)) },
onContinueReadingClicked = { it: LibraryManga -> onContinueReadingClicked = { it: LibraryManga ->
scope.launchIO { scope.launch {
val chapter = viewModel.getNextUnreadChapter(it.manga) val chapter = viewModel.getNextUnreadChapter(it.manga)
if (chapter != null) { if (chapter != null) {
context.startActivity( context.startActivity(
@@ -27,6 +27,7 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -42,14 +43,15 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mihon.core.common.utils.mutate import mihon.core.common.utils.mutate
import mihon.core.metro.AppCoroutineScope
import mihon.domain.library.model.search.QueryNode import mihon.domain.library.model.search.QueryNode
import mihon.feature.library.matches import mihon.feature.library.matches
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.compareToWithCollator 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.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@@ -77,6 +79,7 @@ import kotlin.time.Duration.Companion.seconds
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>()) @ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class LibraryViewModel( class LibraryViewModel(
@AppCoroutineScope private val appScope: CoroutineScope,
private val getLibraryManga: GetLibraryManga, private val getLibraryManga: GetLibraryManga,
private val getCategories: GetCategories, private val getCategories: GetCategories,
private val getTracksPerManga: GetTracksPerManga, private val getTracksPerManga: GetTracksPerManga,
@@ -175,7 +178,7 @@ class LibraryViewModel(
.applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds), .applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds),
) )
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
@@ -482,7 +485,8 @@ class LibraryViewModel(
} }
suspend fun getNextUnreadChapter(manga: Manga): Chapter? { 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?) { private fun downloadNextChapters(amount: Int?) {
val mangas = selectedManga val mangas = selectedManga
viewModelScope.launchNonCancellable { appScope.launch {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getNextChapters.await(manga.id) val chapters = getNextChapters.await(manga.id)
.fastFilterNot { chapter -> .fastFilterNot { chapter ->
@@ -524,7 +528,7 @@ class LibraryViewModel(
private fun downloadBookmarkedChapters() { private fun downloadBookmarkedChapters() {
val mangas = selectedManga val mangas = selectedManga
viewModelScope.launchNonCancellable { appScope.launch {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getBookmarkedChaptersByMangaId.await(manga.id) val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
.fastFilterNot { chapter -> .fastFilterNot { chapter ->
@@ -547,7 +551,7 @@ class LibraryViewModel(
*/ */
fun markReadSelection(read: Boolean) { fun markReadSelection(read: Boolean) {
val selection = selectedManga val selection = selectedManga
viewModelScope.launchNonCancellable { appScope.launch {
selection.forEach { manga -> selection.forEach { manga ->
setReadStatus.await( setReadStatus.await(
manga = manga, manga = manga,
@@ -566,7 +570,7 @@ class LibraryViewModel(
* @param deleteChapters whether to delete downloaded chapters. * @param deleteChapters whether to delete downloaded chapters.
*/ */
fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) { fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
viewModelScope.launchNonCancellable { appScope.launch {
if (deleteFromLibrary) { if (deleteFromLibrary) {
val toDelete = mangas.map { val toDelete = mangas.map {
it.removeCovers(coverCache) it.removeCovers(coverCache)
@@ -596,7 +600,7 @@ class LibraryViewModel(
* @param removeCategories the categories to remove in all mangas. * @param removeCategories the categories to remove in all mangas.
*/ */
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) { fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
viewModelScope.launchNonCancellable { appScope.launch {
mangaList.forEach { manga -> mangaList.forEach { manga ->
val categoryIds = getCategoryIds(manga) val categoryIds = getCategoryIds(manga)
.subtract(removeCategories.toSet()) .subtract(removeCategories.toSet())
@@ -721,7 +725,7 @@ class LibraryViewModel(
} }
fun openChangeCategoryDialog() { fun openChangeCategoryDialog() {
viewModelScope.launchIO { viewModelScope.launch {
// Create a copy of selected manga // Create a copy of selected manga
val mangaList = selectedManga 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.isNavigationBarNeedsScrim
import eu.kanade.tachiyomi.util.system.updaterEnabled import eu.kanade.tachiyomi.util.system.updaterEnabled
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@@ -115,7 +116,6 @@ import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.OpenInNew import mihon.icons.materialsymbols.automirroredrounded.OpenInNew
import mihon.icons.materialsymbols.roundedfilled.VolunteerActivism import mihon.icons.materialsymbols.roundedfilled.VolunteerActivism
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.interactor.GetApplicationRelease
@@ -302,7 +302,7 @@ class MainActivity : BaseActivity() {
setSplashScreenExitAnimation(splashScreen) setSplashScreenExitAnimation(splashScreen)
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) { if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
lifecycleScope.launchIO { lifecycleScope.launch(Dispatchers.IO) {
chapterCache.clear() chapterCache.clear()
} }
} }
@@ -31,11 +31,9 @@ import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.i18n.stringResource 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.core.common.util.system.logcat
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -87,7 +85,7 @@ class MangaCoverViewModel(
viewModelScope.launch { viewModelScope.launch {
try { try {
val uri = saveCoverInternal(context, temp = true) ?: return@launch val uri = saveCoverInternal(context, temp = true) ?: return@launch
withUIContext { withContext(Dispatchers.Main) {
context.startActivity(uri.toShareIntent(context)) context.startActivity(uri.toShareIntent(context))
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -113,11 +111,11 @@ class MangaCoverViewModel(
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.build() .build()
return withIOContext { return withContext(Dispatchers.IO) {
val result = context.imageLoader.execute(req).image?.asDrawable(context.resources) val result = context.imageLoader.execute(req).image?.asDrawable(context.resources)
// TODO: Handle animated cover // TODO: Handle animated cover
val bitmap = result?.getBitmapOrNull() ?: return@withIOContext null val bitmap = result?.getBitmapOrNull() ?: return@withContext null
imageSaver.save( imageSaver.save(
Image.Cover( Image.Cover(
bitmap = bitmap, bitmap = bitmap,
@@ -136,7 +134,7 @@ class MangaCoverViewModel(
*/ */
fun editCover(context: Context, data: Uri) { fun editCover(context: Context, data: Uri) {
val manga = state.value ?: return val manga = state.value ?: return
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
context.contentResolver.openInputStream(data)?.use { context.contentResolver.openInputStream(data)?.use {
try { try {
manga.editCover(coverManager, it, updateManga, coverCache) manga.editCover(coverManager, it, updateManga, coverCache)
@@ -149,7 +147,7 @@ class MangaCoverViewModel(
} }
fun deleteCustomCover(context: Context) { fun deleteCustomCover(context: Context) {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
try { try {
coverCache.deleteCustomCover(mangaId) coverCache.deleteCustomCover(mangaId)
updateManga.awaitUpdateCoverLastModified(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.copyToClipboard
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.feature.migration.config.MigrationConfigScreen import mihon.feature.migration.config.MigrationConfigScreen
import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.feature.migration.dialog.MigrateMangaDialog
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -94,7 +95,7 @@ class MangaScreen(
LaunchedEffect(successState.manga, viewModel.source) { LaunchedEffect(successState.manga, viewModel.source) {
if (isHttpSource) { if (isHttpSource) {
try { try {
withIOContext { withContext(Dispatchers.IO) {
assistUrl = getMangaUrl(viewModel.manga, viewModel.source) assistUrl = getMangaUrl(viewModel.manga, viewModel.source)
} }
} catch (e: Exception) { } 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.removeCovers
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -62,20 +63,20 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope
import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.source.interactor.UpdateMangaFromRemote import mihon.domain.source.interactor.UpdateMangaFromRemote
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.mapAsCheckboxState 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.core.common.util.system.logcat
import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
@@ -105,6 +106,7 @@ import kotlin.time.Duration.Companion.seconds
class MangaViewModel( class MangaViewModel(
@Assisted private val mangaId: Long, @Assisted private val mangaId: Long,
@Assisted private val isFromSource: Boolean, @Assisted private val isFromSource: Boolean,
@AppCoroutineScope private val appScope: CoroutineScope,
private val context: Context, private val context: Context,
private val libraryPreferences: LibraryPreferences, private val libraryPreferences: LibraryPreferences,
trackPreferences: TrackPreferences, trackPreferences: TrackPreferences,
@@ -190,7 +192,7 @@ class MangaViewModel(
private var hasPromptedToAddBefore = false private var hasPromptedToAddBefore = false
private val defaultChapterFlagsJob = viewModelScope.launchIO { private val defaultChapterFlagsJob = viewModelScope.launch {
val manga = getMangaAndChapters.awaitManga(mangaId) val manga = getMangaAndChapters.awaitManga(mangaId)
if (!manga.favorite) { if (!manga.favorite) {
setMangaDefaultChapterFlags.await(manga) setMangaDefaultChapterFlags.await(manga)
@@ -271,19 +273,26 @@ class MangaViewModel(
dialog = dialog, dialog = dialog,
hideMissingChapters = hideMissingChapters, 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) .flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
init { init {
viewModelScope.launchIO { // Progress ticks often, and each one copies the map of download states
viewModelScope.launch(Dispatchers.Default) {
merge(downloadManager.statusFlow(), downloadManager.progressFlow()) merge(downloadManager.statusFlow(), downloadManager.progressFlow())
.filter { it.manga.id == mangaId } .filter { it.manga.id == mangaId }
.catch { logcat(LogPriority.ERROR, it) } .catch { logcat(LogPriority.ERROR, it) }
.collect(::updateDownloadState) .collect(::updateDownloadState)
} }
viewModelScope.launchIO { viewModelScope.launch {
// The state loads the entry and its chapters anyway, so they aren't queried separately // The state loads the entry and its chapters anyway, so they aren't queried separately
val loadedState = state.filterIsInstance<State.Success>().first() val loadedState = state.filterIsInstance<State.Success>().first()
val manga = loadedState.manga val manga = loadedState.manga
@@ -324,7 +333,6 @@ class MangaViewModel(
fetchChapters: Boolean, fetchChapters: Boolean,
) { ) {
try { try {
withUIContext {
val update = updateMangaFromRemote( val update = updateMangaFromRemote(
source = sourceManager.getOrStub(manga.source), source = sourceManager.getOrStub(manga.source),
manga = manga, manga = manga,
@@ -337,7 +345,6 @@ class MangaViewModel(
if (manualFetch) { if (manualFetch) {
downloadNewChapters(update.newChapters) downloadNewChapters(update.newChapters)
} }
}
} catch (_: CancellationException) { } catch (_: CancellationException) {
// ignore // ignore
} catch (e: Exception) { } catch (e: Exception) {
@@ -382,7 +389,7 @@ class MangaViewModel(
checkDuplicate: Boolean = true, checkDuplicate: Boolean = true,
) { ) {
val state = successState ?: return val state = successState ?: return
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val manga = state.manga val manga = state.manga
if (isFavorited) { if (isFavorited) {
@@ -392,7 +399,7 @@ class MangaViewModel(
if (manga.removeCovers(coverCache) != manga) { if (manga.removeCovers(coverCache) != manga) {
updateManga.awaitUpdateCoverLastModified(manga.id) updateManga.awaitUpdateCoverLastModified(manga.id)
} }
withUIContext { onRemoved() } onRemoved()
} }
} else { } else {
// Add to library // Add to library
@@ -402,7 +409,7 @@ class MangaViewModel(
if (duplicates.isNotEmpty()) { if (duplicates.isNotEmpty()) {
dialog.value = Dialog.DuplicateManga(manga, duplicates) dialog.value = Dialog.DuplicateManga(manga, duplicates)
return@launchIO return@launch
} }
} }
@@ -414,14 +421,14 @@ class MangaViewModel(
// Default category set // Default category set
defaultCategory != null -> { defaultCategory != null -> {
val result = updateManga.awaitUpdateFavorite(manga.id, true) val result = updateManga.awaitUpdateFavorite(manga.id, true)
if (!result) return@launchIO if (!result) return@launch
moveMangaToCategory(defaultCategory) moveMangaToCategory(defaultCategory)
} }
// Automatic 'Default' or no categories // Automatic 'Default' or no categories
defaultCategoryId == 0L || categories.isEmpty() -> { defaultCategoryId == 0L || categories.isEmpty() -> {
val result = updateManga.awaitUpdateFavorite(manga.id, true) val result = updateManga.awaitUpdateFavorite(manga.id, true)
if (!result) return@launchIO if (!result) return@launch
moveMangaToCategory(null) moveMangaToCategory(null)
} }
@@ -430,7 +437,7 @@ class MangaViewModel(
} }
// Finally match with enhanced tracking when available // 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) { fun setFetchInterval(manga: Manga, interval: Int) {
viewModelScope.launchIO { viewModelScope.launch {
updateManga.awaitUpdateFetchInterval( updateManga.awaitUpdateFetchInterval(
// Custom intervals are negative // Custom intervals are negative
manga.copy(fetchInterval = -interval), manga.copy(fetchInterval = -interval),
@@ -474,7 +481,7 @@ class MangaViewModel(
*/ */
private fun deleteDownloads() { private fun deleteDownloads() {
val state = successState ?: return 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) moveMangaToCategory(categories)
if (manga.favorite) return if (manga.favorite) return
viewModelScope.launchIO { viewModelScope.launch {
updateManga.awaitUpdateFavorite(manga.id, true) updateManga.awaitUpdateFavorite(manga.id, true)
} }
} }
@@ -517,7 +524,7 @@ class MangaViewModel(
} }
private fun moveMangaToCategory(categoryIds: List<Long>) { private fun moveMangaToCategory(categoryIds: List<Long>) {
viewModelScope.launchIO { viewModelScope.launch {
setMangaCategories.await(mangaId, categoryIds) setMangaCategories.await(mangaId, categoryIds)
} }
} }
@@ -645,15 +652,18 @@ class MangaViewModel(
startNow: Boolean, startNow: Boolean,
) { ) {
if (successState == null) return if (successState == null) return
val startNowId = if (startNow) chapters.singleOrNull()?.id ?: return else null
viewModelScope.launchNonCancellable { viewModelScope.launch {
if (startNow) { // The queueing outlives the screen, the add to library prompt doesn't
val chapterId = chapters.singleOrNull()?.id ?: return@launchNonCancellable appScope.launch {
downloadManager.startDownloadNow(chapterId) if (startNowId != null) {
downloadManager.startDownloadNow(startNowId)
} else { } else {
downloadChapters(chapters) downloadChapters(chapters)
toggleAllSelection(false)
} }
}.join()
if (startNowId == null) toggleAllSelection(false)
if (!isFavorited && !hasPromptedToAddBefore) { if (!isFavorited && !hasPromptedToAddBefore) {
hasPromptedToAddBefore = true hasPromptedToAddBefore = true
@@ -710,9 +720,11 @@ class MangaViewModel(
private fun cancelDownload(chapterId: Long) { private fun cancelDownload(chapterId: Long) {
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
viewModelScope.launch {
downloadManager.cancelQueuedDownloads(listOf(activeDownload)) downloadManager.cancelQueuedDownloads(listOf(activeDownload))
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
} }
}
fun markPreviousChapterRead(pointer: Chapter) { fun markPreviousChapterRead(pointer: Chapter) {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
@@ -730,14 +742,17 @@ class MangaViewModel(
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) { fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
toggleAllSelection(false) toggleAllSelection(false)
if (chapters.isEmpty()) return 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( setReadStatus.await(
read = read, read = read,
chapters = chapters.toTypedArray(), chapters = chapters.toTypedArray(),
) )
}.join()
if (!read || successState?.hasLoggedInTrackers == false || autoTrackState == AutoTrackState.NEVER) { if (!read || successState?.hasLoggedInTrackers == false || autoTrackState == AutoTrackState.NEVER) {
return@launchIO return@launch
} }
refreshTrackers() refreshTrackers()
@@ -746,13 +761,11 @@ class MangaViewModel(
val maxChapterNumber = chapters.maxOf { it.chapterNumber } val maxChapterNumber = chapters.maxOf { it.chapterNumber }
val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead } val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead }
if (!shouldPromptTrackingUpdate) return@launchIO if (!shouldPromptTrackingUpdate) return@launch
if (autoTrackState == AutoTrackState.ALWAYS) { if (autoTrackState == AutoTrackState.ALWAYS) {
trackChapter.await(context, mangaId, maxChapterNumber) trackChapter.await(context, mangaId, maxChapterNumber)
withUIContext {
context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt())) context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt()))
} return@launch
return@launchIO
} }
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
@@ -775,7 +788,7 @@ class MangaViewModel(
logcat(LogPriority.ERROR, e) { logcat(LogPriority.ERROR, e) {
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}" "Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
} }
withUIContext { withContext(Dispatchers.Main) {
context.toast( context.toast(
context.stringResource( context.stringResource(
MR.strings.track_error, MR.strings.track_error,
@@ -801,7 +814,7 @@ class MangaViewModel(
* @param chapters the list of chapters to bookmark. * @param chapters the list of chapters to bookmark.
*/ */
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) { fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
viewModelScope.launchIO { viewModelScope.launch {
chapters chapters
.filterNot { it.bookmark == bookmarked } .filterNot { it.bookmark == bookmarked }
.map { ChapterUpdate(it.id) { bookmark = bookmarked } } .map { ChapterUpdate(it.id) { bookmark = bookmarked } }
@@ -816,24 +829,20 @@ class MangaViewModel(
* @param chapters the list of chapters to delete. * @param chapters the list of chapters to delete.
*/ */
fun deleteChapters(chapters: List<Chapter>) { 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 { try {
successState?.let { state -> downloadManager.deleteChapters(chapters, state.manga, state.source)
downloadManager.deleteChapters( } catch (e: Exception) {
chapters,
state.manga,
state.source,
)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
} }
} }
} }
private fun downloadNewChapters(chapters: List<Chapter>) { private fun downloadNewChapters(chapters: List<Chapter>) {
viewModelScope.launchNonCancellable { appScope.launch {
val manga = successState?.manga ?: return@launchNonCancellable val manga = successState?.manga ?: return@launch
val chaptersToDownload = filterChaptersForDownload.await(manga, chapters) val chaptersToDownload = filterChaptersForDownload.await(manga, chapters)
if (chaptersToDownload.isNotEmpty()) { if (chaptersToDownload.isNotEmpty()) {
@@ -854,7 +863,7 @@ class MangaViewModel(
TriState.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD TriState.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ
} }
viewModelScope.launchNonCancellable { appScope.launch {
setMangaChapterFlags.awaitSetUnreadFilter(manga, flag) setMangaChapterFlags.awaitSetUnreadFilter(manga, flag)
} }
} }
@@ -872,7 +881,7 @@ class MangaViewModel(
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED
} }
viewModelScope.launchNonCancellable { appScope.launch {
setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag) setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag)
} }
} }
@@ -890,7 +899,7 @@ class MangaViewModel(
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED
} }
viewModelScope.launchNonCancellable { appScope.launch {
setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag) setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag)
} }
} }
@@ -902,7 +911,7 @@ class MangaViewModel(
fun setDisplayMode(mode: Long) { fun setDisplayMode(mode: Long) {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
viewModelScope.launchNonCancellable { appScope.launch {
setMangaChapterFlags.awaitSetDisplayMode(manga, mode) setMangaChapterFlags.awaitSetDisplayMode(manga, mode)
} }
} }
@@ -914,25 +923,27 @@ class MangaViewModel(
fun setSorting(sort: Long) { fun setSorting(sort: Long) {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
viewModelScope.launchNonCancellable { appScope.launch {
setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort) setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort)
} }
} }
fun setCurrentSettingsAsDefault(applyToExisting: Boolean) { fun setCurrentSettingsAsDefault(applyToExisting: Boolean) {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
viewModelScope.launchNonCancellable { viewModelScope.launch {
appScope.launch {
libraryPreferences.setChapterSettingsDefault(manga) libraryPreferences.setChapterSettingsDefault(manga)
if (applyToExisting) { if (applyToExisting) {
setMangaDefaultChapterFlags.awaitAll() setMangaDefaultChapterFlags.awaitAll()
} }
}.join()
snackbarHostState.showSnackbar(message = context.stringResource(MR.strings.chapter_settings_updated)) snackbarHostState.showSnackbar(message = context.stringResource(MR.strings.chapter_settings_updated))
} }
} }
fun resetToDefaultSettings() { fun resetToDefaultSettings() {
val manga = successState?.manga ?: return val manga = successState?.manga ?: return
viewModelScope.launchNonCancellable { viewModelScope.launch {
setMangaDefaultChapterFlags.await(manga) setMangaDefaultChapterFlags.await(manga)
} }
} }
@@ -1046,7 +1057,7 @@ class MangaViewModel(
} }
fun setExcludedScanlators(excludedScanlators: Set<String>) { fun setExcludedScanlators(excludedScanlators: Set<String>) {
viewModelScope.launchIO { viewModelScope.launch {
setExcludedScanlators.await(mangaId, excludedScanlators) setExcludedScanlators.await(mangaId, excludedScanlators)
} }
} }
@@ -1127,11 +1138,12 @@ class MangaViewModel(
val unreadFilter = manga.unreadFilter val unreadFilter = manga.unreadFilter
val downloadedFilter = manga.downloadedFilter val downloadedFilter = manga.downloadedFilter
val bookmarkedFilter = manga.bookmarkedFilter val bookmarkedFilter = manga.bookmarkedFilter
val chapterSort = getChapterSort(manga)
return asSequence() return asSequence()
.filter { (chapter) -> applyFilter(unreadFilter) { !chapter.read } } .filter { (chapter) -> applyFilter(unreadFilter) { !chapter.read } }
.filter { (chapter) -> applyFilter(bookmarkedFilter) { chapter.bookmark } } .filter { (chapter) -> applyFilter(bookmarkedFilter) { chapter.bookmark } }
.filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalManga } } .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 dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
import eu.kanade.presentation.manga.MangaNotesScreen import eu.kanade.presentation.manga.MangaNotesScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update 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.interactor.UpdateMangaNotes
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@@ -45,6 +48,7 @@ class MangaNotesScreen(
@AssistedInject @AssistedInject
class Model( class Model(
@Assisted private val manga: Manga, @Assisted private val manga: Manga,
@AppCoroutineScope private val appScope: CoroutineScope,
private val updateMangaNotes: UpdateMangaNotes, private val updateMangaNotes: UpdateMangaNotes,
) : ViewModel() { ) : ViewModel() {
@@ -65,7 +69,8 @@ class MangaNotesScreen(
it.copy(notes = content) 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) 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.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -67,16 +69,15 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.core.metro.AppCoroutineScope
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.roundedfilled.Delete import mihon.icons.materialsymbols.roundedfilled.Delete
import tachiyomi.core.common.i18n.stringResource 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.core.common.util.system.logcat
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
@@ -201,6 +202,7 @@ data class TrackInfoDialogHomeScreen(
class Model( class Model(
@Assisted private val mangaId: Long, @Assisted private val mangaId: Long,
@Assisted private val sourceId: Long, @Assisted private val sourceId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
private val context: Context, private val context: Context,
private val getTracks: GetTracks, private val getTracks: GetTracks,
private val getManga: GetManga, private val getManga: GetManga,
@@ -235,13 +237,13 @@ data class TrackInfoDialogHomeScreen(
fun registerEnhancedTracking(item: TrackItem) { fun registerEnhancedTracking(item: TrackItem) {
item.tracker as EnhancedTracker item.tracker as EnhancedTracker
viewModelScope.launchNonCancellable { appScope.launch {
val manga = getManga.await(mangaId) ?: return@launchNonCancellable val manga = getManga.await(mangaId) ?: return@launch
try { try {
val matchResult = item.tracker.match(manga) ?: throw Exception() val matchResult = item.tracker.match(manga) ?: throw Exception()
item.tracker.register(matchResult, mangaId) item.tracker.register(matchResult, mangaId)
} catch (_: Exception) { } catch (_: Exception) {
withUIContext { withContext(Dispatchers.Main) {
context.toast(MR.strings.error_no_match) context.toast(MR.strings.error_no_match)
} }
} }
@@ -255,7 +257,7 @@ data class TrackInfoDialogHomeScreen(
logcat(LogPriority.ERROR, e) { logcat(LogPriority.ERROR, e) {
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}" "Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
} }
withUIContext { withContext(Dispatchers.Main) {
context.toast( context.toast(
context.stringResource( context.stringResource(
MR.strings.track_error, MR.strings.track_error,
@@ -268,7 +270,7 @@ data class TrackInfoDialogHomeScreen(
} }
fun togglePrivate(item: TrackItem) { fun togglePrivate(item: TrackItem) {
viewModelScope.launchNonCancellable { appScope.launch {
item.tracker.setRemotePrivate(item.track!!.toDbTrack(), !item.track.private) item.tracker.setRemotePrivate(item.track!!.toDbTrack(), !item.track.private)
} }
} }
@@ -316,6 +318,7 @@ data class TrackStatusSelectorScreen(
class Model( class Model(
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -340,7 +343,7 @@ data class TrackStatusSelectorScreen(
} }
fun setStatus() { fun setStatus() {
viewModelScope.launchNonCancellable { appScope.launch {
tracker.setRemoteStatus(track.toDbTrack(), state.value.selection) tracker.setRemoteStatus(track.toDbTrack(), state.value.selection)
} }
} }
@@ -379,6 +382,7 @@ data class TrackChapterSelectorScreen(
class Model( class Model(
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -408,7 +412,7 @@ data class TrackChapterSelectorScreen(
} }
fun setChapter() { fun setChapter() {
viewModelScope.launchNonCancellable { appScope.launch {
tracker.setRemoteLastChapterRead(track.toDbTrack(), state.value.selection) tracker.setRemoteLastChapterRead(track.toDbTrack(), state.value.selection)
} }
} }
@@ -447,6 +451,7 @@ data class TrackScoreSelectorScreen(
class Model( class Model(
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -475,7 +480,7 @@ data class TrackScoreSelectorScreen(
} }
fun setScore() { fun setScore() {
viewModelScope.launchNonCancellable { appScope.launch {
tracker.setRemoteScore(track.toDbTrack(), state.value.selection) tracker.setRemoteScore(track.toDbTrack(), state.value.selection)
} }
} }
@@ -574,6 +579,7 @@ data class TrackDateSelectorScreen(
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@Assisted private val start: Boolean, @Assisted private val start: Boolean,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -597,7 +603,7 @@ data class TrackDateSelectorScreen(
fun setDate(millis: Long) { fun setDate(millis: Long) {
// Convert to local time // Convert to local time
val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault()) val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault())
viewModelScope.launchNonCancellable { appScope.launch {
if (start) { if (start) {
tracker.setRemoteStartDate(track.toDbTrack(), localMillis) tracker.setRemoteStartDate(track.toDbTrack(), localMillis)
} else { } else {
@@ -678,6 +684,7 @@ data class TrackDateRemoverScreen(
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@Assisted private val start: Boolean, @Assisted private val start: Boolean,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -693,7 +700,7 @@ data class TrackDateRemoverScreen(
fun getServiceName() = tracker.name fun getServiceName() = tracker.name
fun removeDate() { fun removeDate() {
viewModelScope.launchNonCancellable { appScope.launch {
if (start) { if (start) {
tracker.setRemoteStartDate(track.toDbTrack(), 0) tracker.setRemoteStartDate(track.toDbTrack(), 0)
} else { } else {
@@ -749,6 +756,7 @@ data class TrackerSearchScreen(
@Assisted private val currentUrl: String?, @Assisted private val currentUrl: String?,
@Assisted initialQuery: String, @Assisted initialQuery: String,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -783,7 +791,7 @@ data class TrackerSearchScreen(
// To show loading state // To show loading state
state.update { it.copy(queryResult = null, selected = null) } state.update { it.copy(queryResult = null, selected = null) }
val result = withIOContext { val result = withContext(Dispatchers.IO) {
try { try {
val results = tracker.search(query) val results = tracker.search(query)
Result.success(results) Result.success(results)
@@ -801,7 +809,7 @@ data class TrackerSearchScreen(
} }
fun registerTracking(item: TrackSearch) { fun registerTracking(item: TrackSearch) {
viewModelScope.launchNonCancellable { tracker.register(item, mangaId) } appScope.launch { tracker.register(item, mangaId) }
} }
fun updateSelection(selected: TrackSearch) { fun updateSelection(selected: TrackSearch) {
@@ -895,6 +903,7 @@ data class TrackerRemoveScreen(
@Assisted private val mangaId: Long, @Assisted private val mangaId: Long,
@Assisted private val track: Track, @Assisted private val track: Track,
@Assisted private val trackerId: Long, @Assisted private val trackerId: Long,
@AppCoroutineScope private val appScope: CoroutineScope,
private val deleteTrack: DeleteTrack, private val deleteTrack: DeleteTrack,
trackerManager: TrackerManager, trackerManager: TrackerManager,
) : ViewModel() { ) : ViewModel() {
@@ -913,7 +922,7 @@ data class TrackerRemoveScreen(
fun isDeletable() = tracker is DeletableTracker fun isDeletable() = tracker is DeletableTracker
fun deleteMangaFromService() { fun deleteMangaFromService() {
viewModelScope.launchNonCancellable { appScope.launch {
try { try {
(tracker as DeletableTracker).delete(track) (tracker as DeletableTracker).delete(track)
} catch (e: Exception) { } catch (e: Exception) {
@@ -923,7 +932,7 @@ data class TrackerRemoveScreen(
} }
fun unregisterTracking(serviceId: Long) { 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.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import mihon.feature.support.SupportUsScreen import mihon.feature.support.SupportUsScreen
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
@@ -95,7 +95,7 @@ class MoreViewModel(
init { init {
// Handle running/paused status change and queue progress updating // Handle running/paused status change and queue progress updating
viewModelScope.launchIO { viewModelScope.launch {
combine( combine(
downloadManager.isDownloaderRunning, downloadManager.isDownloaderRunning,
downloadManager.queueState, 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.getUriCompat
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
@@ -60,7 +61,7 @@ class NewUpdateScreenModel(
downloadJob = viewModelScope.launch { downloadJob = viewModelScope.launch {
state.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) } state.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) }
try { try {
withIOContext { downloadApk() } withContext(Dispatchers.IO) { downloadApk() }
state.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) } state.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e 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.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@@ -95,8 +96,6 @@ import mihon.app.di.AppGraph
import mihon.core.metro.metroGraph import mihon.core.metro.metroGraph
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.core.common.i18n.stringResource 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.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
@@ -345,9 +344,7 @@ class ReaderActivity : BaseActivity() {
} }
override fun onPause() { override fun onPause() {
lifecycleScope.launchNonCancellable { viewModel.saveHistory()
viewModel.updateHistory()
}
super.onPause() super.onPause()
} }
@@ -621,7 +618,7 @@ class ReaderActivity : BaseActivity() {
binding.readerContainer.removeView(loadingIndicator) binding.readerContainer.removeView(loadingIndicator)
viewModel.state.value.viewer?.setChapters(viewerChapters) viewModel.state.value.viewer?.setChapters(viewerChapters)
lifecycleScope.launchIO { lifecycleScope.launch(Dispatchers.IO) {
viewModel.getChapterUrl()?.let { url -> viewModel.getChapterUrl()?.let { url ->
assistUrl = 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. * the viewer is reaching the beginning or end of a chapter or the transition page is active.
*/ */
fun requestPreloadChapter(chapter: ReaderChapter) { 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -67,14 +69,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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 logcat.LogPriority
import mihon.core.metro.AppCoroutineScope import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.preference.toggle 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.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.interactor.UpdateChapter
@@ -191,25 +191,32 @@ class ReaderViewModel(
*/ */
private var chapterReadStartTime: Instant? = null 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 var chapterToDownload: Download? = null
private val unfilteredChapterList by lazy { private var unfilteredChapterList: List<Chapter>? = null
val manga = manga!!
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) } 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 * Chapter list for the active manga. Loaded by [init].
* time in a background thread to avoid blocking the UI.
*/ */
private val chapterList by lazy { private lateinit var chapterList: List<ReaderChapter>
private suspend fun loadChapterList(): List<ReaderChapter> {
val manga = manga!! val manga = manga!!
val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) } val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true)
.let { filtered -> .let { filtered ->
if (filtered.any { it.id == chapterId }) { if (filtered.any { it.id == chapterId }) {
filtered filtered
} else { } else {
filtered + unfilteredChapterList.filter { it.id == chapterId } filtered + getUnfilteredChapterList().filter { it.id == chapterId }
} }
} }
@@ -260,7 +267,7 @@ class ReaderViewModel(
else -> chapters else -> chapters
} }
chaptersForReader return chaptersForReader
.sortedWith(getChapterSort(manga, sortDescending = false)) .sortedWith(getChapterSort(manga, sortDescending = false))
.run { .run {
if (readerPreferences.skipDupe.get()) { if (readerPreferences.skipDupe.get()) {
@@ -308,7 +315,8 @@ class ReaderViewModel(
if (currentChapters != null) { if (currentChapters != null) {
currentChapters.unref() currentChapters.unref()
chapterToDownload?.let { 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]. * Failures are reported through [State.initError].
*/ */
private suspend fun init() { private suspend fun init() {
withIOContext { withContext(Dispatchers.IO) {
try { try {
val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found") val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found")
val source = sourceManager.getOrStub(manga.source) val source = sourceManager.getOrStub(manga.source)
@@ -345,6 +353,7 @@ class ReaderViewModel(
source, source,
) )
chapterList = loadChapterList()
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) { if (e is CancellationException) {
@@ -372,13 +381,14 @@ class ReaderViewModel(
chapterList.getOrNull(chapterPos + 1), chapterList.getOrNull(chapterPos + 1),
) )
withUIContext { withContext(Dispatchers.Main) {
val toDownload = cancelQueuedDownloads(newChapters.currChapter)
mutableState.update { mutableState.update {
// Add new references first to avoid unnecessary recycling // Add new references first to avoid unnecessary recycling
newChapters.ref() newChapters.ref()
it.viewerChapters?.unref() it.viewerChapters?.unref()
chapterToDownload = cancelQueuedDownloads(newChapters.currChapter) chapterToDownload = toDownload
it.copy( it.copy(
viewerChapters = newChapters, viewerChapters = newChapters,
bookmarked = newChapters.currChapter.chapter.bookmark, bookmarked = newChapters.currChapter.chapter.bookmark,
@@ -395,7 +405,7 @@ class ReaderViewModel(
private fun loadNewChapter(chapter: ReaderChapter) { private fun loadNewChapter(chapter: ReaderChapter) {
val loader = loader ?: return val loader = loader ?: return
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
logcat { "Loading ${chapter.chapter.url}" } logcat { "Loading ${chapter.chapter.url}" }
updateHistory() updateHistory()
@@ -422,7 +432,7 @@ class ReaderViewModel(
mutableState.update { it.copy(isLoadingAdjacentChapter = true) } mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
try { try {
withIOContext { withContext(Dispatchers.IO) {
loadChapter(loader, chapter) loadChapter(loader, chapter)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -497,8 +507,8 @@ class ReaderViewModel(
val selectedChapter = page.chapter val selectedChapter = page.chapter
val pages = selectedChapter.pages ?: return val pages = selectedChapter.pages ?: return
// Save last page read and mark as read if needed // Save last page read and mark as read if needed. The database calls are main-safe.
viewModelScope.launchNonCancellable { appScope.launch(Dispatchers.Main.immediate) {
updateChapterProgress(selectedChapter, page) updateChapterProgress(selectedChapter, page)
} }
@@ -523,7 +533,7 @@ class ReaderViewModel(
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val isNextChapterDownloaded = downloadManager.isChapterDownloaded( val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
nextChapter.name, nextChapter.name,
nextChapter.scanlator, nextChapter.scanlator,
@@ -531,7 +541,7 @@ class ReaderViewModel(
manga.title, manga.title,
manga.source, manga.source,
) )
if (!isNextChapterDownloaded) return@launchIO if (!isNextChapterDownloaded) return@launch
val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id).run { val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id).run {
if (readerPreferences.skipDupe.get()) { if (readerPreferences.skipDupe.get()) {
@@ -552,7 +562,7 @@ class ReaderViewModel(
* Removes [currentChapter] from download queue * Removes [currentChapter] from download queue
* if setting is enabled and [currentChapter] is queued for download * 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 { return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id)?.also {
downloadManager.cancelQueuedDownloads(listOf(it)) downloadManager.cancelQueuedDownloads(listOf(it))
} }
@@ -563,7 +573,7 @@ class ReaderViewModel(
* If both conditions are satisfied enqueues chapter for delete * If both conditions are satisfied enqueues chapter for delete
* @param currentChapter current chapter, which is going to be marked as read. * @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() val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots.get()
if (removeAfterReadSlots == -1) return if (removeAfterReadSlots == -1) return
@@ -579,7 +589,7 @@ class ReaderViewModel(
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead.get() val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead.get()
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING) .contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
val duplicateChapters = if (markDuplicateAsRead) { val duplicateChapters = if (markDuplicateAsRead) {
unfilteredChapterList getUnfilteredChapterList()
.filter { .filter {
it.id != chapterToDelete.chapter.id && it.id != chapterToDelete.chapter.id &&
it.isRecognizedNumber && it.isRecognizedNumber &&
@@ -609,8 +619,12 @@ class ReaderViewModel(
if (!incognitoMode && page.status !is Page.State.Error) { if (!incognitoMode && page.status !is Page.State.Error) {
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) } 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) updateChapterProgressOnComplete(readerChapter)
} }
@@ -622,6 +636,7 @@ class ReaderViewModel(
) )
} }
} }
}
private suspend fun updateChapterProgressOnComplete(readerChapter: ReaderChapter) { private suspend fun updateChapterProgressOnComplete(readerChapter: ReaderChapter) {
updateTrackChapterRead(readerChapter) updateTrackChapterRead(readerChapter)
@@ -631,7 +646,7 @@ class ReaderViewModel(
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING) .contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
if (!markDuplicateAsRead) return if (!markDuplicateAsRead) return
val duplicateUnreadChapters = unfilteredChapterList val duplicateUnreadChapters = getUnfilteredChapterList()
.mapNotNull { chapter -> .mapNotNull { chapter ->
if ( if (
!chapter.read && !chapter.read &&
@@ -650,10 +665,18 @@ class ReaderViewModel(
chapterReadStartTime = Clock.System.now() 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. * Saves the chapter last read history if incognito mode isn't on.
*/ */
suspend fun updateHistory() { private suspend fun updateHistory() {
getCurrentChapter()?.let { readerChapter -> getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let if (incognitoMode) return@let
@@ -711,7 +734,8 @@ class ReaderViewModel(
val bookmarked = !readerChapter.chapter.bookmark val bookmarked = !readerChapter.chapter.bookmark
readerChapter.update { it.copy(bookmark = bookmarked) } readerChapter.update { it.copy(bookmark = bookmarked) }
viewModelScope.launchNonCancellable { // On Main so quick toggles are saved in order
viewModelScope.launch {
updateChapter.await( updateChapter.await(
ChapterUpdate(readerChapter.chapter.id) { ChapterUpdate(readerChapter.chapter.id) {
bookmark = bookmarked bookmark = bookmarked
@@ -738,25 +762,25 @@ class ReaderViewModel(
} }
} }
private var readingModeJob: Job? = null
/** /**
* Updates the viewer position for the open manga. * Updates the viewer position for the open manga.
*/ */
fun setMangaReadingMode(readingMode: ReadingMode) { fun setMangaReadingMode(readingMode: ReadingMode) {
val manga = manga ?: return 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()) setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
val updatedManga = getManga.await(manga.id)
val currChapters = state.value.viewerChapters val currChapters = state.value.viewerChapters
if (currChapters != null) { if (currChapters != null) {
// Save current page // Save current page
val currChapter = currChapters.currChapter val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.lastPageRead currChapter.requestedPage = currChapter.chapter.lastPageRead
mutableState.update { mutableState.update { it.copy(manga = updatedManga) }
it.copy(
manga = getManga.await(manga.id),
viewerChapters = currChapters,
)
}
eventChannel.send(Event.ReloadViewerChapters) eventChannel.send(Event.ReloadViewerChapters)
} }
} }
@@ -774,25 +798,25 @@ class ReaderViewModel(
} }
} }
private var orientationJob: Job? = null
/** /**
* Updates the orientation type for the open manga. * Updates the orientation type for the open manga.
*/ */
fun setMangaOrientationType(orientation: ReaderOrientation) { fun setMangaOrientationType(orientation: ReaderOrientation) {
val manga = manga ?: return 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()) setMangaViewerFlags.awaitSetOrientation(manga.id, orientation.flagValue.toLong())
val updatedManga = getManga.await(manga.id)
val currChapters = state.value.viewerChapters val currChapters = state.value.viewerChapters
if (currChapters != null) { if (currChapters != null) {
// Save current page // Save current page
val currChapter = currChapters.currChapter val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.lastPageRead currChapter.requestedPage = currChapter.chapter.lastPageRead
mutableState.update { mutableState.update { it.copy(manga = updatedManga) }
it.copy(
manga = getManga.await(manga.id),
viewerChapters = currChapters,
)
}
eventChannel.send(Event.SetOrientation(getMangaOrientation())) eventChannel.send(Event.SetOrientation(getMangaOrientation()))
eventChannel.send(Event.ReloadViewerChapters) eventChannel.send(Event.ReloadViewerChapters)
} }
@@ -878,8 +902,9 @@ class ReaderViewModel(
"" ""
} }
// Copy file in background. // The save and its notification outlive the reader, the result event doesn't
viewModelScope.launchNonCancellable { viewModelScope.launch {
val result = appScope.async {
try { try {
val uri = imageSaver.save( val uri = imageSaver.save(
image = Image.Page( image = Image.Page(
@@ -888,14 +913,14 @@ class ReaderViewModel(
location = Location.Pictures.create(relativePath), location = Location.Pictures.create(relativePath),
), ),
) )
withUIContext { withContext(Dispatchers.Main) { notifier.onComplete(uri) }
notifier.onComplete(uri) SaveImageResult.Success(uri)
eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri)))
}
} catch (e: Throwable) { } catch (e: Throwable) {
notifier.onError(e.message) 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) val filename = generateFilename(manga, page)
viewModelScope.launch(Dispatchers.IO) {
try { try {
viewModelScope.launchNonCancellable {
destDir.deleteRecursively() destDir.deleteRecursively()
val uri = imageSaver.save( val uri = imageSaver.save(
image = Image.Page( image = Image.Page(
@@ -926,11 +951,12 @@ class ReaderViewModel(
), ),
) )
eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page)) eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page))
} } catch (e: Exception) {
} catch (e: Throwable) { if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
} }
} }
}
/** /**
* Sets the image of the selected page as cover and notifies the UI of the result. * 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 manga = manga ?: return
val stream = page.stream ?: return val stream = page.stream ?: return
viewModelScope.launchNonCancellable { viewModelScope.launch {
val result = try { val result = appScope.async {
try {
manga.editCover(coverManager, stream(), updateManga, coverCache) manga.editCover(coverManager, stream(), updateManga, coverCache)
if (manga.isLocal() || manga.favorite) { if (manga.isLocal() || manga.favorite) {
SetAsCoverResult.Success SetAsCoverResult.Success
@@ -952,6 +979,7 @@ class ReaderViewModel(
} catch (e: Exception) { } catch (e: Exception) {
SetAsCoverResult.Error SetAsCoverResult.Error
} }
}.await()
eventChannel.send(Event.SetCoverResult(result)) eventChannel.send(Event.SetCoverResult(result))
} }
} }
@@ -977,7 +1005,8 @@ class ReaderViewModel(
val manga = manga ?: return 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) 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 * Enqueues these [chapters] to be deleted when [deletePendingChapters] is called. The download
* manager handles persisting them across process deaths. * manager handles persisting them across process deaths.
*/ */
private fun enqueueDeleteChapters(chapters: List<Chapter>) { private suspend fun enqueueDeleteChapters(chapters: List<Chapter>) {
val manga = manga ?: return val manga = manga ?: return
viewModelScope.launchNonCancellable {
downloadManager.enqueueChaptersToDelete(chapters, manga) downloadManager.enqueueChaptersToDelete(chapters, manga)
} }
}
/** /**
* Deletes all the pending chapters. This operation will run in a background thread and errors * Deletes all the pending chapters. This operation will run in a background thread and errors
* are ignored. * are ignored.
*/ */
private fun deletePendingChapters() { private fun deletePendingChapters() {
viewModelScope.launchNonCancellable { appScope.launch {
// Progress writes started before leaving may still enqueue the chapter just finished
progressWrites.withLock {}
downloadManager.deletePendingChapters() downloadManager.deletePendingChapters()
} }
} }
@@ -9,10 +9,11 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mihon.core.archive.archiveReader import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader import mihon.core.archive.epubReader
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
@@ -43,7 +44,7 @@ class ChapterLoader(
} }
chapter.state = ReaderChapter.State.Loading chapter.state = ReaderChapter.State.Loading
withIOContext { withContext(Dispatchers.IO) {
logcat { "Loading pages for ${chapter.chapter.name}" } logcat { "Loading pages for ${chapter.chapter.name}" }
try { try {
val loader = getPageLoader(chapter) val loader = getPageLoader(chapter)
@@ -80,7 +81,7 @@ class ChapterLoader(
/** /**
* Returns the page loader to use for this [chapter]. * 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 dbChapter = chapter.chapter
val isDownloaded = downloadManager.isChapterDownloadedOnDisk( val isDownloaded = downloadManager.isChapterDownloadedOnDisk(
dbChapter.name, dbChapter.name,
@@ -56,7 +56,7 @@ internal class DownloadPageLoader(
return loader.getPages() return loader.getPages()
} }
private fun getPagesFromDirectory(): List<ReaderPage> { private suspend fun getPagesFromDirectory(): List<ReaderPage> {
val pages = downloadManager.buildPageList(source, manga, chapter.chapter) val pages = downloadManager.buildPageList(source, manga, chapter.chapter)
return pages.map { page -> return pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl) { 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import tachiyomi.core.common.util.lang.launchIO import kotlinx.coroutines.withContext
import tachiyomi.core.common.util.lang.withIOContext
import java.util.concurrent.PriorityBlockingQueue import java.util.concurrent.PriorityBlockingQueue
import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -42,8 +44,14 @@ internal class HttpPageLoader(
private val preloadSize = 4 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 { init {
scope.launchIO { scope.launch(Dispatchers.IO) {
flow { flow {
while (true) { while (true) {
emit(runInterruptible { queue.take() }) emit(runInterruptible { queue.take() })
@@ -51,11 +59,18 @@ internal class HttpPageLoader(
} }
.filter { it.page.status == Page.State.Queue } .filter { it.page.status == Page.State.Queue }
.collect { .collect {
coroutineScope {
val load = launch {
internalLoadPage( internalLoadPage(
page = it.page, page = it.page,
force = it.priority == PriorityPage.RETRY, 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 // 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. // stays on the caller's thread, so requests queue in the order they were made.
if (page.status == Page.State.Ready && imageUrl != null && if (page.status == Page.State.Ready && imageUrl != null &&
!withIOContext { chapterCache.isImageInCache(imageUrl) } !withContext(Dispatchers.IO) { chapterCache.isImageInCache(imageUrl) }
) { ) {
page.status = Page.State.Queue page.status = Page.State.Queue
} }
@@ -99,11 +114,23 @@ internal class HttpPageLoader(
page.status = Page.State.Queue 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>() val queuedPages = mutableListOf<PriorityPage>()
if (page.status == Page.State.Queue) { if (page.status == Page.State.Queue) {
queuedPages += PriorityPage(page, PriorityPage.DEFAULT).also { queue.offer(it) } 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 -> suspendCancellableCoroutine<Nothing> { continuation ->
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
@@ -112,6 +139,13 @@ internal class HttpPageLoader(
queue.remove(it) 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 // Cache current page list progress for online chapters to allow a faster reopen
chapter.pages?.let { pages -> chapter.pages?.let { pages ->
appScope.launchIO { appScope.launch(Dispatchers.IO) {
try { try {
// Convert to pages without reader information // Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } 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. * Returns the [amount] of pages after [currentPage], which are preloaded with a lower priority.
*
* @return a list of [PriorityPage] that were added to the [queue]
*/ */
private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List<PriorityPage> { private fun nextPages(currentPage: ReaderPage, amount: Int): List<ReaderPage> {
val pageIndex = currentPage.index val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return emptyList() val pages = currentPage.chapter.pages ?: return emptyList()
if (pageIndex == pages.lastIndex) return emptyList() if (pageIndex == pages.lastIndex) return emptyList()
return pages return pages.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
.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
}
}
} }
/** /**
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition 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?) { fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
data = if (manga != null && source != null) { data = if (manga != null && source != null) {
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
Data( Data(
transition = transition, transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true, currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() || 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( downloadManager.isChapterDownloadedOnDisk(
chapterName = goingToChapter.name, chapterName = it.name,
chapterScanlator = goingToChapter.scanlator, chapterScanlator = it.scanlator,
chapterUrl = goingToChapter.url, chapterUrl = it.url,
mangaTitle = manga.title, mangaTitle = manga.title,
source = source, source = source,
) )
} ?: false, }
},
) )
} else { } else {
null null
@@ -52,6 +65,9 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
@Composable @Composable
override fun Content() { override fun Content() {
data?.let { data?.let {
val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
}
TachiyomiTheme { TachiyomiTheme {
CompositionLocalProvider( CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall, LocalTextStyle provides MaterialTheme.typography.bodySmall,
@@ -60,7 +76,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
ChapterTransition( ChapterTransition(
transition = it.transition, transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded, currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = it.goingToChapterDownloaded, goingToChapterDownloaded = goingToChapterDownloaded,
) )
} }
} }
@@ -71,5 +87,6 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
val transition: ChapterTransition, val transition: ChapterTransition,
val currChapterDownloaded: Boolean, val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: 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.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.widget.ViewPagerAdapter import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource 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.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -94,7 +93,7 @@ class PagerPageHolder(
val loader = page.chapter.pageLoader ?: return val loader = page.chapter.pageLoader ?: return
supervisorScope { supervisorScope {
launchIO { launch(Dispatchers.IO) {
loader.loadPage(page) loader.loadPage(page)
} }
page.statusFlow.collectLatest { state -> page.statusFlow.collectLatest { state ->
@@ -150,8 +149,10 @@ class PagerPageHolder(
val streamFn = page.stream ?: return val streamFn = page.stream ?: return
try { try {
val (source, isAnimated, background) = withIOContext { val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
val source = streamFn().use { process(item, 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 isAnimated = ImageUtil.isAnimatedAndSupported(source)
val background = if (!isAnimated && viewer.config.automaticBackground) { val background = if (!isAnimated && viewer.config.automaticBackground) {
ImageUtil.chooseBackground(context, source.peek().inputStream()) ImageUtil.chooseBackground(context, source.peek().inputStream())
@@ -160,7 +161,7 @@ class PagerPageHolder(
} }
Triple(source, isAnimated, background) Triple(source, isAnimated, background)
} }
withUIContext { withContext(Dispatchers.Main) {
setImage( setImage(
source, source,
isAnimated, isAnimated,
@@ -179,7 +180,7 @@ class PagerPageHolder(
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
withUIContext { withContext(Dispatchers.Main) {
setError(e) setError(e)
} }
} }
@@ -482,7 +482,7 @@ open class WebGpuViewer(
if (!chapterPreloadsInFlight.add(chapterId)) return if (!chapterPreloadsInFlight.add(chapterId)) return
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.IO) {
try { try {
activity.viewModel.preload(chapter) activity.viewModel.preload(chapter)
repeat(25) { 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.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource 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.ImageUtil
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -134,7 +133,7 @@ class WebtoonPageHolder(
val page = page ?: return val page = page ?: return
val loader = page.chapter.pageLoader ?: return val loader = page.chapter.pageLoader ?: return
supervisorScope { supervisorScope {
launchIO { launch(Dispatchers.IO) {
loader.loadPage(page) loader.loadPage(page)
} }
page.statusFlow.collectLatest { state -> page.statusFlow.collectLatest { state ->
@@ -190,12 +189,14 @@ class WebtoonPageHolder(
val streamFn = page?.stream ?: return val streamFn = page?.stream ?: return
try { try {
val (source, isAnimated) = withIOContext { val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
val source = streamFn().use { process(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) val isAnimated = ImageUtil.isAnimatedAndSupported(source)
Pair(source, isAnimated) Pair(source, isAnimated)
} }
withUIContext { withContext(Dispatchers.Main) {
frame.setImage( frame.setImage(
source, source,
isAnimated, isAnimated,
@@ -209,7 +210,7 @@ class WebtoonPageHolder(
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
withUIContext { withContext(Dispatchers.Main) {
setError(e) 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.download.DownloadManager
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update 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.history.interactor.GetTotalReadDuration
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
@@ -48,7 +49,7 @@ class StatsViewModel(
private val loggedInTrackers by lazy { trackerManager.loggedInTrackers() } private val loggedInTrackers by lazy { trackerManager.loggedInTrackers() }
init { init {
viewModelScope.launchIO { viewModelScope.launch(Dispatchers.IO) {
val libraryManga = getLibraryManga.await() val libraryManga = getLibraryManga.await()
val distinctLibraryManga = libraryManga.fastDistinctBy { it.id } 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.data.library.LibraryUpdateWorker
import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -46,9 +47,8 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus import kotlinx.datetime.minus
import logcat.LogPriority import logcat.LogPriority
import mihon.core.metro.AppCoroutineScope
import tachiyomi.core.common.preference.TriState 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.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.GetChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.interactor.UpdateChapter
@@ -67,6 +67,7 @@ import kotlin.time.Duration.Companion.seconds
@ViewModelKey @ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>()) @ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
class UpdatesViewModel( class UpdatesViewModel(
@AppCoroutineScope private val appScope: CoroutineScope,
private val context: Context, private val context: Context,
private val sourceManager: SourceManager, private val sourceManager: SourceManager,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
@@ -103,7 +104,7 @@ class UpdatesViewModel(
} }
init { init {
viewModelScope.launchIO { viewModelScope.launch {
merge(downloadManager.statusFlow(), downloadManager.progressFlow()) merge(downloadManager.statusFlow(), downloadManager.progressFlow())
.catch { logcat(LogPriority.ERROR, it) } .catch { logcat(LogPriority.ERROR, it) }
.collect(this@UpdatesViewModel::updateDownloadState) .collect(this@UpdatesViewModel::updateDownloadState)
@@ -167,7 +168,7 @@ class UpdatesViewModel(
.toUpdateItems() .toUpdateItems()
.applyFilters(itemPreferences) .applyFilters(itemPreferences)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
val state: StateFlow<State> = combine( val state: StateFlow<State> = combine(
@@ -199,6 +200,7 @@ class UpdatesViewModel(
dialog = dialog, dialog = dialog,
) )
} }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
private fun List<UpdatesItem>.applyFilters( private fun List<UpdatesItem>.applyFilters(
@@ -242,11 +244,9 @@ class UpdatesViewModel(
} }
} }
fun updateLibrary(): Boolean { suspend fun updateLibrary(): Boolean {
val started = LibraryUpdateWorker.startNow(context.workManager) val started = LibraryUpdateWorker.startNow(context.workManager)
viewModelScope.launch {
_events.send(Event.LibraryUpdateTriggered(started)) _events.send(Event.LibraryUpdateTriggered(started))
}
return started return started
} }
@@ -276,11 +276,11 @@ class UpdatesViewModel(
} }
} }
private fun startDownloadingNow(chapterId: Long) { private suspend fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapterId) downloadManager.startDownloadNow(chapterId)
} }
private fun cancelDownload(chapterId: Long) { private suspend fun cancelDownload(chapterId: Long) {
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
downloadManager.cancelQueuedDownloads(listOf(activeDownload)) downloadManager.cancelQueuedDownloads(listOf(activeDownload))
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
@@ -292,7 +292,7 @@ class UpdatesViewModel(
* @param read whether to mark chapters as read or unread. * @param read whether to mark chapters as read or unread.
*/ */
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) { fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
viewModelScope.launchIO { appScope.launch {
setReadStatus.await( setReadStatus.await(
read = read, read = read,
chapters = updates chapters = updates
@@ -308,7 +308,7 @@ class UpdatesViewModel(
* @param updates the list of chapters to bookmark. * @param updates the list of chapters to bookmark.
*/ */
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) { fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
viewModelScope.launchIO { viewModelScope.launch {
updates updates
.filterNot { it.update.bookmark == bookmark } .filterNot { it.update.bookmark == bookmark }
.map { ChapterUpdate(it.update.chapterId) { this.bookmark = bookmark } } .map { ChapterUpdate(it.update.chapterId) { this.bookmark = bookmark } }
@@ -322,7 +322,7 @@ class UpdatesViewModel(
* @param updatesItem the list of chapters to download. * @param updatesItem the list of chapters to download.
*/ */
private fun downloadChapters(updatesItem: List<UpdatesItem>) { private fun downloadChapters(updatesItem: List<UpdatesItem>) {
viewModelScope.launchNonCancellable { appScope.launch {
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
for (updates in groupedUpdates) { for (updates in groupedUpdates) {
val mangaId = updates.first().update.mangaId val mangaId = updates.first().update.mangaId
@@ -341,7 +341,7 @@ class UpdatesViewModel(
* @param updatesItem list of chapters * @param updatesItem list of chapters
*/ */
fun deleteChapters(updatesItem: List<UpdatesItem>) { fun deleteChapters(updatesItem: List<UpdatesItem>) {
viewModelScope.launchNonCancellable { appScope.launch {
updatesItem updatesItem
.groupBy { it.update.mangaId } .groupBy { it.update.mangaId }
.entries .entries
@@ -372,8 +372,8 @@ class UpdatesViewModel(
val currentSelection = selectedChapterIds.value val currentSelection = selectedChapterIds.value
if ((item.update.chapterId in currentSelection) == selected) return 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 // Only visible items count, since the id set can retain ids filtered out of the list
val firstSelection = items.none { it.selected } val firstSelection = items.none { it.update.chapterId in currentSelection }
val newSelection = currentSelection.toHashSet() val newSelection = currentSelection.toHashSet()
newSelection.addOrRemove(item.update.chapterId, selected) 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.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setComposeContent import eu.kanade.tachiyomi.util.view.setComposeContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -67,13 +69,15 @@ class WebViewActivity : BaseActivity() {
// Null until the source it belongs to has been resolved // Null until the source it belongs to has been resolved
val headers by produceState<Map<String, String>?>(initialValue = null) { val headers by produceState<Map<String, String>?>(initialValue = null) {
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource 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() source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to build headers" } logcat(LogPriority.ERROR, e) { "Failed to build headers" }
emptyMap() emptyMap()
} }
} }
}
if (headers == null) { if (headers == null) {
LoadingScreen() LoadingScreen()

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