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