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 dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.domain.download.interactor.DeleteDownload
|
import eu.kanade.domain.download.interactor.DeleteDownload
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||||
@@ -27,7 +26,7 @@ class SetReadStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun await(read: Boolean, vararg chapters: Chapter): Result = withNonCancellableContext {
|
suspend fun await(read: Boolean, vararg chapters: Chapter): Result {
|
||||||
val chaptersToUpdate = chapters.filter {
|
val chaptersToUpdate = chapters.filter {
|
||||||
when (read) {
|
when (read) {
|
||||||
true -> !it.read
|
true -> !it.read
|
||||||
@@ -35,7 +34,7 @@ class SetReadStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (chaptersToUpdate.isEmpty()) {
|
if (chaptersToUpdate.isEmpty()) {
|
||||||
return@withNonCancellableContext Result.NoChapters
|
return Result.NoChapters
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +43,7 @@ class SetReadStatus(
|
|||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
return@withNonCancellableContext Result.InternalError(e)
|
return Result.InternalError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (read && downloadPreferences.removeAfterMarkedAsRead.get()) {
|
if (read && downloadPreferences.removeAfterMarkedAsRead.get()) {
|
||||||
@@ -61,17 +60,16 @@ class SetReadStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.Success
|
return Result.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun await(mangaId: Long, read: Boolean): Result = withNonCancellableContext {
|
suspend fun await(mangaId: Long, read: Boolean): Result =
|
||||||
await(
|
await(
|
||||||
read = read,
|
read = read,
|
||||||
chapters = chapterRepository
|
chapters = chapterRepository
|
||||||
.getChapterByMangaId(mangaId)
|
.getChapterByMangaId(mangaId)
|
||||||
.toTypedArray(),
|
.toTypedArray(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun await(manga: Manga, read: Boolean) =
|
suspend fun await(manga: Manga, read: Boolean) =
|
||||||
await(manga.id, read)
|
await(manga.id, read)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class SyncChaptersWithSource(
|
|||||||
// The source no longer lists these, so their queued downloads could only fail
|
// The source no longer lists these, so their queued downloads could only fail
|
||||||
removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) }
|
removedChapters.mapNotNull { downloadManager.getQueuedDownloadOrNull(it.id) }
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
?.let(downloadManager::cancelQueuedDownloads)
|
?.let { downloadManager.cancelQueuedDownloads(it) }
|
||||||
|
|
||||||
// Return if there's nothing to add, delete, or update to avoid unnecessary db transactions.
|
// Return if there's nothing to add, delete, or update to avoid unnecessary db transactions.
|
||||||
if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) {
|
if (newChapters.isEmpty() && removedChapters.isEmpty() && updatedChapters.isEmpty()) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package eu.kanade.domain.download.interactor
|
|||||||
|
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
@@ -13,7 +12,7 @@ class DeleteDownload(
|
|||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) = withNonCancellableContext {
|
suspend fun awaitAll(manga: Manga, vararg chapters: Chapter) {
|
||||||
sourceManager.get(manga.source)?.let { source ->
|
sourceManager.get(manga.source)?.let { source ->
|
||||||
downloadManager.deleteChapters(chapters.toList(), manga, source)
|
downloadManager.deleteChapters(chapters.toList(), manga, source)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fun Manga.copyFrom(other: SManga): Manga {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get<Context>().appGraph.coverCache): Boolean {
|
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get<Context>().appGraph.coverCache): Boolean {
|
||||||
return coverCache.getCustomCoverFile(id).exists()
|
return coverCache.hasCustomCover(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import android.content.Context
|
|||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.domain.source.model.Source
|
import tachiyomi.domain.source.model.Source
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
suspend fun Source.icon(): ImageBitmap? = withIOContext {
|
suspend fun Source.icon(): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
Injekt.get<Context>().appGraph.extensionManager.getAppIconForSource(id)
|
Injekt.get<Context>().appGraph.extensionManager.getAppIconForSource(id)
|
||||||
?.toBitmap()
|
?.toBitmap()
|
||||||
?.asImageBitmap()
|
?.asImageBitmap()
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import eu.kanade.tachiyomi.data.track.Tracker
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
@@ -30,13 +30,12 @@ class AddTracks(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
// TODO: update all trackers based on common data
|
// TODO: update all trackers based on common data
|
||||||
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withNonCancellableContext {
|
suspend fun bind(tracker: Tracker, item: Track, mangaId: Long) = withContext(Dispatchers.IO) {
|
||||||
withIOContext {
|
|
||||||
val allChapters = getChaptersByMangaId.await(mangaId)
|
val allChapters = getChaptersByMangaId.await(mangaId)
|
||||||
val hasReadChapters = allChapters.any { it.read }
|
val hasReadChapters = allChapters.any { it.read }
|
||||||
tracker.bind(item, hasReadChapters)
|
tracker.bind(item, hasReadChapters)
|
||||||
|
|
||||||
var track = item.toDomainTrack(idRequired = false) ?: return@withIOContext
|
var track = item.toDomainTrack(idRequired = false) ?: return@withContext
|
||||||
|
|
||||||
upsertTrack.await(track)
|
upsertTrack.await(track)
|
||||||
|
|
||||||
@@ -76,10 +75,8 @@ class AddTracks(
|
|||||||
|
|
||||||
syncChapterProgressWithTrack.await(mangaId, track, tracker)
|
syncChapterProgressWithTrack.await(mangaId, track, tracker)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withNonCancellableContext {
|
suspend fun bindEnhancedTrackers(manga: Manga, source: Source) = withContext(Dispatchers.IO) {
|
||||||
withIOContext {
|
|
||||||
trackerManager.loggedInTrackers()
|
trackerManager.loggedInTrackers()
|
||||||
.filterIsInstance<EnhancedTracker>()
|
.filterIsInstance<EnhancedTracker>()
|
||||||
.filter { it.accept(source) }
|
.filter { it.accept(source) }
|
||||||
@@ -105,4 +102,3 @@ class AddTracks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import eu.kanade.domain.track.model.toDbTrack
|
|||||||
import eu.kanade.domain.track.model.toDomainTrack
|
import eu.kanade.domain.track.model.toDomainTrack
|
||||||
import eu.kanade.tachiyomi.data.track.Tracker
|
import eu.kanade.tachiyomi.data.track.Tracker
|
||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
@@ -30,7 +31,7 @@ class RefreshTracks(
|
|||||||
.map { it to trackerManager.get(it.trackerId) }
|
.map { it to trackerManager.get(it.trackerId) }
|
||||||
.filter { (_, service) -> service?.isLoggedIn == true }
|
.filter { (_, service) -> service?.isLoggedIn == true }
|
||||||
.map { (track, service) ->
|
.map { (track, service) ->
|
||||||
async {
|
async(Dispatchers.IO) {
|
||||||
return@async try {
|
return@async try {
|
||||||
val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!!
|
val updatedTrack = service!!.refresh(track.toDbTrack()).toDomainTrack()!!
|
||||||
upsertTrack.await(updatedTrack)
|
upsertTrack.await(updatedTrack)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import eu.kanade.domain.track.store.DelayedTrackingStore
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.track.interactor.GetTracks
|
import tachiyomi.domain.track.interactor.GetTracks
|
||||||
import tachiyomi.domain.track.interactor.UpsertTrack
|
import tachiyomi.domain.track.interactor.UpsertTrack
|
||||||
@@ -24,9 +24,9 @@ class TrackChapter(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) {
|
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double, setupJobOnFailure: Boolean = true) {
|
||||||
withNonCancellableContext {
|
coroutineScope {
|
||||||
val tracks = getTracks.await(mangaId)
|
val tracks = getTracks.await(mangaId)
|
||||||
if (tracks.isEmpty()) return@withNonCancellableContext
|
if (tracks.isEmpty()) return@coroutineScope
|
||||||
|
|
||||||
tracks.mapNotNull { track ->
|
tracks.mapNotNull { track ->
|
||||||
val service = trackerManager.get(track.trackerId)
|
val service = trackerManager.get(track.trackerId)
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import dev.zacsweers.metro.Inject
|
|||||||
import eu.kanade.domain.track.interactor.TrackChapter
|
import eu.kanade.domain.track.interactor.TrackChapter
|
||||||
import eu.kanade.domain.track.store.DelayedTrackingStore
|
import eu.kanade.domain.track.store.DelayedTrackingStore
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.AppGraph
|
import mihon.app.di.AppGraph
|
||||||
import mihon.core.metro.metroGraph
|
import mihon.core.metro.metroGraph
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.track.interactor.GetTracks
|
import tachiyomi.domain.track.interactor.GetTracks
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -38,7 +39,7 @@ class DelayedTrackingUpdateWorker(private val context: Context, workerParams: Wo
|
|||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
delayedTrackingStore.getItems()
|
delayedTrackingStore.getItems()
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
val track = getTracks.awaitOne(it.trackId)
|
val track = getTracks.awaitOne(it.trackId)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DelayedTrackingStore(context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Preference file where queued tracking updates are stored.
|
* Preference file where queued tracking updates are stored.
|
||||||
*/
|
*/
|
||||||
private val preferences = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
|
private val preferences by lazy { context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
fun add(trackId: Long, lastChapterRead: Double) {
|
fun add(trackId: Long, lastChapterRead: Double) {
|
||||||
val previousLastChapterRead = preferences.getFloat(trackId.toString(), 0f)
|
val previousLastChapterRead = preferences.getFloat(trackId.toString(), 0f)
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Dangerous
|
import mihon.icons.materialsymbols.roundedfilled.Dangerous
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Warning
|
import mihon.icons.materialsymbols.roundedfilled.Warning
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.domain.source.model.Source
|
import tachiyomi.domain.source.model.Source
|
||||||
import tachiyomi.source.local.isLocal
|
import tachiyomi.source.local.isLocal
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ fun ExtensionIcon(
|
|||||||
private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State<Result<ImageBitmap>> {
|
private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State<Result<ImageBitmap>> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
|
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
value = try {
|
value = try {
|
||||||
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!!
|
val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!!
|
||||||
val appResources = context.packageManager.getResourcesForApplication(appInfo)
|
val appResources = context.packageManager.getResourcesForApplication(appInfo)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ fun LibraryContent(
|
|||||||
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
||||||
onToggleSelection: (Category, LibraryManga) -> Unit,
|
onToggleSelection: (Category, LibraryManga) -> Unit,
|
||||||
onToggleRangeSelection: (Category, LibraryManga) -> Unit,
|
onToggleRangeSelection: (Category, LibraryManga) -> Unit,
|
||||||
onRefresh: () -> Boolean,
|
onRefresh: suspend () -> Boolean,
|
||||||
onGlobalSearchClicked: () -> Unit,
|
onGlobalSearchClicked: () -> Unit,
|
||||||
getItemCountForCategory: (Category) -> Int?,
|
getItemCountForCategory: (Category) -> Int?,
|
||||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||||
@@ -80,9 +80,8 @@ fun LibraryContent(
|
|||||||
refreshing = isRefreshing,
|
refreshing = isRefreshing,
|
||||||
enabled = selection.isEmpty(),
|
enabled = selection.isEmpty(),
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
val started = onRefresh()
|
|
||||||
if (!started) return@PullRefresh
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (!onRefresh()) return@launch
|
||||||
// Fake refresh status but hide it after a second as it's a long running task
|
// Fake refresh status but hide it after a second as it's a long running task
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -44,6 +45,7 @@ import coil3.asDrawable
|
|||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import coil3.request.CachePolicy
|
import coil3.request.CachePolicy
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.SuccessResult
|
||||||
import coil3.size.Size
|
import coil3.size.Size
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
@@ -53,7 +55,7 @@ import eu.kanade.tachiyomi.data.coil.ImageDecoder
|
|||||||
import eu.kanade.tachiyomi.data.coil.newDecoder
|
import eu.kanade.tachiyomi.data.coil.newDecoder
|
||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.Close
|
import mihon.icons.materialsymbols.rounded.Close
|
||||||
@@ -167,19 +169,20 @@ fun MangaCoverDialog(
|
|||||||
},
|
},
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
if (useNewRenderer) {
|
if (useNewRenderer) {
|
||||||
val state = ImageViewerState()
|
val state = remember(manga) { ImageViewerState() }
|
||||||
|
LaunchedEffect(manga) {
|
||||||
ImageRequest.Builder(view.context)
|
val request = ImageRequest.Builder(view.context)
|
||||||
.data(manga)
|
.data(manga)
|
||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
.newDecoder(true)
|
.newDecoder(true)
|
||||||
.target { result ->
|
.build()
|
||||||
val res = (result as ImageDecoder.DecodeResultImage)
|
val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect
|
||||||
|
val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect
|
||||||
// Held, then freed, around the upload: its buffer alone doesn't keep
|
// Held, then freed, around the upload: its buffer alone doesn't keep
|
||||||
// the frame's native pixels alive.
|
// the frame's native pixels alive.
|
||||||
val page = res.frame.use {
|
val page = res.frame.use {
|
||||||
runBlocking(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
ImagePage.ImageSingle(
|
ImagePage.ImageSingle(
|
||||||
Image(
|
Image(
|
||||||
res.image,
|
res.image,
|
||||||
@@ -213,8 +216,6 @@ fun MangaCoverDialog(
|
|||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.build()
|
|
||||||
.let(view.context.imageLoader::enqueue)
|
|
||||||
|
|
||||||
ImageViewer(state = state)
|
ImageViewer(state = state)
|
||||||
return@Scaffold
|
return@Scaffold
|
||||||
|
|||||||
+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.setDefaultSettings
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -193,6 +193,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
): Preference.PreferenceGroup {
|
): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val networkHelper = remember { context.appGraph.networkHelper }
|
val networkHelper = remember { context.appGraph.networkHelper }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val userAgentPref = networkPreferences.defaultUserAgent
|
val userAgentPref = networkPreferences.defaultUserAgent
|
||||||
val userAgent by userAgentPref.collectAsState()
|
val userAgent by userAgentPref.collectAsState()
|
||||||
@@ -219,8 +220,10 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
clearSslPreferences()
|
clearSslPreferences()
|
||||||
}
|
}
|
||||||
WebStorage.getInstance().deleteAllData()
|
WebStorage.getInstance().deleteAllData()
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
|
context.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
|
||||||
context.toast(MR.strings.webview_data_deleted)
|
withContext(Dispatchers.Main) { context.toast(MR.strings.webview_data_deleted) }
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
context.toast(MR.strings.cache_delete_error)
|
context.toast(MR.strings.cache_delete_error)
|
||||||
@@ -289,15 +292,14 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
preferenceItems = listOf(
|
preferenceItems = listOf(
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.pref_refresh_library_covers),
|
title = stringResource(MR.strings.pref_refresh_library_covers),
|
||||||
onClick = { MetadataUpdateWorker.startNow(context.workManager) },
|
onClick = { scope.launch { MetadataUpdateWorker.startNow(context.workManager) } },
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.pref_reset_viewer_flags),
|
title = stringResource(MR.strings.pref_reset_viewer_flags),
|
||||||
subtitle = stringResource(MR.strings.pref_reset_viewer_flags_summary),
|
subtitle = stringResource(MR.strings.pref_reset_viewer_flags_summary),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launchNonCancellable {
|
scope.launch {
|
||||||
val success = context.appGraph.resetViewerFlags.await()
|
val success = context.appGraph.resetViewerFlags.await()
|
||||||
withUIContext {
|
|
||||||
val message = if (success) {
|
val message = if (success) {
|
||||||
MR.strings.pref_reset_viewer_flags_success
|
MR.strings.pref_reset_viewer_flags_success
|
||||||
} else {
|
} else {
|
||||||
@@ -305,7 +307,6 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
}
|
}
|
||||||
context.toast(message)
|
context.toast(message)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.SwitchPreference(
|
Preference.PreferenceItem.SwitchPreference(
|
||||||
|
|||||||
+14
-9
@@ -28,6 +28,7 @@ import androidx.compose.runtime.ReadOnlyComposable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -53,16 +54,16 @@ import eu.kanade.tachiyomi.data.export.LibraryExporter.ExportOptions
|
|||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.automirroredrounded.Help
|
import mihon.icons.materialsymbols.automirroredrounded.Help
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.storage.displayablePath
|
import tachiyomi.core.common.storage.displayablePath
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.backup.service.BackupPreferences
|
import tachiyomi.domain.backup.service.BackupPreferences
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -181,6 +182,7 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup {
|
private fun getBackupAndRestoreGroup(backupPreferences: BackupPreferences): Preference.PreferenceGroup {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp.collectAsState()
|
val lastAutoBackup by backupPreferences.lastAutoBackupTimestamp.collectAsState()
|
||||||
|
|
||||||
@@ -231,6 +233,7 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
checked = false,
|
checked = false,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
|
scope.launch {
|
||||||
if (!BackupRestoreWorker.isRunning(context.workManager)) {
|
if (!BackupRestoreWorker.isRunning(context.workManager)) {
|
||||||
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
||||||
context.toast(MR.strings.restore_miui_warning)
|
context.toast(MR.strings.restore_miui_warning)
|
||||||
@@ -239,6 +242,7 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
} else {
|
} else {
|
||||||
context.toast(MR.strings.restore_in_progress)
|
context.toast(MR.strings.restore_in_progress)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
shape = SegmentedButtonDefaults.itemShape(1, 2),
|
shape = SegmentedButtonDefaults.itemShape(1, 2),
|
||||||
) {
|
) {
|
||||||
@@ -282,7 +286,9 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
|
|
||||||
val chapterCache = remember { context.appGraph.chapterCache }
|
val chapterCache = remember { context.appGraph.chapterCache }
|
||||||
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
|
var cacheReadableSizeSema by remember { mutableIntStateOf(0) }
|
||||||
val cacheReadableSize = remember(cacheReadableSizeSema) { chapterCache.readableSize }
|
val cacheReadableSize by produceState("", cacheReadableSizeSema) {
|
||||||
|
value = withContext(Dispatchers.IO) { chapterCache.readableSize }
|
||||||
|
}
|
||||||
|
|
||||||
return Preference.PreferenceGroup(
|
return Preference.PreferenceGroup(
|
||||||
title = stringResource(MR.strings.pref_storage_usage),
|
title = stringResource(MR.strings.pref_storage_usage),
|
||||||
@@ -303,16 +309,15 @@ object SettingsDataScreen : SearchableSettings {
|
|||||||
title = stringResource(MR.strings.pref_clear_chapter_cache),
|
title = stringResource(MR.strings.pref_clear_chapter_cache),
|
||||||
subtitle = stringResource(MR.strings.used_cache, cacheReadableSize),
|
subtitle = stringResource(MR.strings.used_cache, cacheReadableSize),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launchNonCancellable {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val deletedFiles = chapterCache.clear()
|
val deletedFiles = withContext(Dispatchers.IO) { chapterCache.clear() }
|
||||||
withUIContext {
|
|
||||||
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
|
context.toast(context.stringResource(MR.strings.cache_deleted, deletedFiles))
|
||||||
cacheReadableSizeSema++
|
cacheReadableSizeSema++
|
||||||
}
|
} catch (e: Exception) {
|
||||||
} catch (e: Throwable) {
|
if (e is CancellationException) throw e
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
withUIContext { context.toast(MR.strings.cache_delete_error) }
|
context.toast(MR.strings.cache_delete_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+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.data.track.shikimori.ShikimoriApi
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.automirroredrounded.Help
|
import mihon.icons.materialsymbols.automirroredrounded.Help
|
||||||
import mihon.icons.materialsymbols.rounded.Close
|
import mihon.icons.materialsymbols.rounded.Close
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Visibility
|
import mihon.icons.materialsymbols.roundedfilled.Visibility
|
||||||
import mihon.icons.materialsymbols.roundedfilled.VisibilityOff
|
import mihon.icons.materialsymbols.roundedfilled.VisibilityOff
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -276,7 +277,7 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !processing && username.text.isNotBlank() && password.text.isNotBlank(),
|
enabled = !processing && username.text.isNotBlank() && password.text.isNotBlank(),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launchIO {
|
scope.launch(Dispatchers.IO) {
|
||||||
processing = true
|
processing = true
|
||||||
val result = checkLogin(
|
val result = checkLogin(
|
||||||
context = context,
|
context = context,
|
||||||
@@ -305,11 +306,11 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
return try {
|
return try {
|
||||||
tracker.login(username, password)
|
tracker.login(username, password)
|
||||||
withUIContext { context.toast(MR.strings.login_success) }
|
withContext(Dispatchers.Main) { context.toast(MR.strings.login_success) }
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
tracker.logout()
|
tracker.logout()
|
||||||
withUIContext { context.toast(e.message.toString()) }
|
withContext(Dispatchers.Main) { context.toast(e.message.toString()) }
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ import eu.kanade.tachiyomi.util.system.isFossBuildType
|
|||||||
import eu.kanade.tachiyomi.util.system.isNightlyBuildType
|
import eu.kanade.tachiyomi.util.system.isNightlyBuildType
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.system.updaterEnabled
|
import eu.kanade.tachiyomi.util.system.updaterEnabled
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
@@ -50,8 +52,6 @@ import mihon.icons.simpleicons.Reddit
|
|||||||
import mihon.icons.simpleicons.SimpleIcons
|
import mihon.icons.simpleicons.SimpleIcons
|
||||||
import mihon.icons.simpleicons.X
|
import mihon.icons.simpleicons.X
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -218,9 +218,9 @@ object AboutScreen : Screen() {
|
|||||||
onFinish: () -> Unit,
|
onFinish: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val updateChecker = context.appGraph.updateChecker
|
val updateChecker = context.appGraph.updateChecker
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
when (val result = withIOContext { updateChecker.checkForUpdate(forceCheck = true) }) {
|
when (val result = updateChecker.checkForUpdate(forceCheck = true)) {
|
||||||
is GetApplicationRelease.Result.NewUpdate -> {
|
is GetApplicationRelease.Result.NewUpdate -> {
|
||||||
onAvailableUpdate(result)
|
onAvailableUpdate(result)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -42,16 +42,17 @@ import eu.kanade.presentation.components.AppBar
|
|||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.FlipToBack
|
import mihon.icons.materialsymbols.rounded.FlipToBack
|
||||||
import mihon.icons.materialsymbols.rounded.SelectAll
|
import mihon.icons.materialsymbols.rounded.SelectAll
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchUI
|
|
||||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
|
||||||
import tachiyomi.domain.history.repository.HistoryRepository
|
import tachiyomi.domain.history.repository.HistoryRepository
|
||||||
import tachiyomi.domain.manga.repository.MangaRepository
|
import tachiyomi.domain.manga.repository.MangaRepository
|
||||||
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
|
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
|
||||||
@@ -117,7 +118,7 @@ class ClearDatabaseScreen : Screen() {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launchUI {
|
scope.launch(Dispatchers.Main) {
|
||||||
viewModel.removeMangaBySourceId(keepReadManga)
|
viewModel.removeMangaBySourceId(keepReadManga)
|
||||||
viewModel.clearSelection()
|
viewModel.clearSelection()
|
||||||
viewModel.hideConfirmation()
|
viewModel.hideConfirmation()
|
||||||
@@ -229,6 +230,7 @@ class ClearDatabaseScreen : Screen() {
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||||
class ClearDatabaseViewModel(
|
class ClearDatabaseViewModel(
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val mangaRepository: MangaRepository,
|
private val mangaRepository: MangaRepository,
|
||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga,
|
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga,
|
||||||
@@ -238,7 +240,7 @@ class ClearDatabaseViewModel(
|
|||||||
field = MutableStateFlow<ClearDatabaseViewModel.State>(State.Loading)
|
field = MutableStateFlow<ClearDatabaseViewModel.State>(State.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getSourcesWithNonLibraryManga.subscribe()
|
getSourcesWithNonLibraryManga.subscribe()
|
||||||
.collectLatest { list ->
|
.collectLatest { list ->
|
||||||
state.update { old ->
|
state.update { old ->
|
||||||
@@ -252,10 +254,13 @@ class ClearDatabaseViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext {
|
suspend fun removeMangaBySourceId(keepReadManga: Boolean) {
|
||||||
val state = state.value as? State.Ready ?: return@withNonCancellableContext
|
val state = state.value as? State.Ready ?: return
|
||||||
|
// Finishes even if the screen is left while it runs
|
||||||
|
appScope.launch {
|
||||||
mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga)
|
mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga)
|
||||||
historyRepository.deleteResetHistory()
|
historyRepository.deleteResetHistory()
|
||||||
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSelection(source: Source) = state.update { state ->
|
fun toggleSelection(source: Source) = state.update { state ->
|
||||||
|
|||||||
+3
-4
@@ -24,7 +24,6 @@ import mihon.domain.extension.interactor.GetExtensionStores
|
|||||||
import mihon.domain.extension.interactor.RemoveExtensionStore
|
import mihon.domain.extension.interactor.RemoveExtensionStore
|
||||||
import mihon.domain.extension.interactor.UpdateExtensionStores
|
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||||
import mihon.domain.extension.model.ExtensionStore
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -90,7 +89,7 @@ class ExtensionStoresViewModel(
|
|||||||
* Refreshes information for each repository.
|
* Refreshes information for each repository.
|
||||||
*/
|
*/
|
||||||
fun refreshRepos() {
|
fun refreshRepos() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
updateExtensionStores()
|
updateExtensionStores()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +98,14 @@ class ExtensionStoresViewModel(
|
|||||||
* Deletes the given repo from the database
|
* Deletes the given repo from the database
|
||||||
*/
|
*/
|
||||||
fun deleteRepo(baseUrl: String) {
|
fun deleteRepo(baseUrl: String) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
removeExtensionStore(baseUrl)
|
removeExtensionStore(baseUrl)
|
||||||
extensionManager.findAvailableExtensions()
|
extensionManager.findAvailableExtensions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addFromDeeplink(storeIndexUrl: String) {
|
fun addFromDeeplink(storeIndexUrl: String) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val alreadyExists = getExtensionStores.get().any { it.indexUrl == storeIndexUrl }
|
val alreadyExists = getExtensionStores.get().any { it.indexUrl == storeIndexUrl }
|
||||||
dialog.update { ExtensionStoreDialog.Confirm(url = storeIndexUrl, alreadyExists = alreadyExists) }
|
dialog.update { ExtensionStoreDialog.Confirm(url = storeIndexUrl, alreadyExists = alreadyExists) }
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
@@ -31,6 +32,7 @@ import eu.kanade.tachiyomi.util.system.toast
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||||
@@ -46,6 +48,7 @@ class CreateBackupScreen : Screen() {
|
|||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
val viewModel = metroViewModel<CreateBackupViewModel>()
|
val viewModel = metroViewModel<CreateBackupViewModel>()
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val chooseBackupDir = rememberLauncherForActivityResult(
|
val chooseBackupDir = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/*"),
|
contract = ActivityResultContracts.CreateDocument("application/*"),
|
||||||
@@ -75,6 +78,7 @@ class CreateBackupScreen : Screen() {
|
|||||||
actionLabel = stringResource(MR.strings.action_create),
|
actionLabel = stringResource(MR.strings.action_create),
|
||||||
actionEnabled = state.options.canCreate(),
|
actionEnabled = state.options.canCreate(),
|
||||||
onClickAction = {
|
onClickAction = {
|
||||||
|
scope.launch {
|
||||||
if (!BackupCreateWorker.isManualJobRunning(context)) {
|
if (!BackupCreateWorker.isManualJobRunning(context)) {
|
||||||
try {
|
try {
|
||||||
chooseBackupDir.launch(BackupCreator.getFilename())
|
chooseBackupDir.launch(BackupCreator.getFilename())
|
||||||
@@ -84,6 +88,7 @@ class CreateBackupScreen : Screen() {
|
|||||||
} else {
|
} else {
|
||||||
context.toast(MR.strings.backup_in_progress)
|
context.toast(MR.strings.backup_in_progress)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
|
||||||
|
|||||||
+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.data.backup.restore.RestoreOptions
|
||||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
import eu.kanade.tachiyomi.util.system.DeviceUtil
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||||
@@ -193,7 +194,7 @@ class RestoreBackupViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
validate(uri.toUri())
|
validate(uri.toUri())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
@@ -27,7 +31,9 @@ fun StorageInfo(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val storages = remember { DiskUtil.getExternalStorages(context) }
|
val storages by produceState(emptyList<File>()) {
|
||||||
|
value = withContext(Dispatchers.IO) { DiskUtil.getExternalStorages(context) }
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -45,9 +51,14 @@ private fun StorageInfo(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val available = remember(file) { DiskUtil.getAvailableStorageSpace(file) }
|
val space by produceState(0L to 0L, file) {
|
||||||
|
value =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
DiskUtil.getAvailableStorageSpace(file) to DiskUtil.getTotalStorageSpace(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val (available, total) = space
|
||||||
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
|
val availableText = remember(available) { Formatter.formatFileSize(context, available) }
|
||||||
val total = remember(file) { DiskUtil.getTotalStorageSpace(file) }
|
|
||||||
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
|
val totalText = remember(total) { Formatter.formatFileSize(context, total) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -63,7 +74,7 @@ private fun StorageInfo(
|
|||||||
.clip(MaterialTheme.shapes.small)
|
.clip(MaterialTheme.shapes.small)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(12.dp),
|
.height(12.dp),
|
||||||
progress = { (1 - (available / total.toFloat())) },
|
progress = { if (total > 0) 1 - (available / total.toFloat()) else 0f },
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ fun UpdateScreen(
|
|||||||
onSelectAll: (Boolean) -> Unit,
|
onSelectAll: (Boolean) -> Unit,
|
||||||
onInvertSelection: () -> Unit,
|
onInvertSelection: () -> Unit,
|
||||||
onCalendarClicked: () -> Unit,
|
onCalendarClicked: () -> Unit,
|
||||||
onUpdateLibrary: () -> Boolean,
|
onUpdateLibrary: suspend () -> Boolean,
|
||||||
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
|
||||||
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
|
||||||
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
|
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
|
||||||
@@ -68,11 +68,13 @@ fun UpdateScreen(
|
|||||||
onSelectAll(false)
|
onSelectAll(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { scrollBehavior ->
|
topBar = { scrollBehavior ->
|
||||||
UpdatesAppBar(
|
UpdatesAppBar(
|
||||||
onCalendarClicked = { onCalendarClicked() },
|
onCalendarClicked = { onCalendarClicked() },
|
||||||
onUpdateLibrary = { onUpdateLibrary() },
|
onUpdateLibrary = { scope.launch { onUpdateLibrary() } },
|
||||||
onFilterClicked = { onFilterClicked() },
|
onFilterClicked = { onFilterClicked() },
|
||||||
hasFilters = hasActiveFilters,
|
hasFilters = hasActiveFilters,
|
||||||
actionModeCounter = state.selected.size,
|
actionModeCounter = state.selected.size,
|
||||||
@@ -96,15 +98,13 @@ fun UpdateScreen(
|
|||||||
when {
|
when {
|
||||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||||
else -> {
|
else -> {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
PullRefresh(
|
PullRefresh(
|
||||||
refreshing = isRefreshing,
|
refreshing = isRefreshing,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
val started = onUpdateLibrary()
|
|
||||||
if (!started) return@PullRefresh
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (!onUpdateLibrary()) return@launch
|
||||||
// Fake refresh status but hide it after a second as it's a long running task
|
// Fake refresh status but hide it after a second as it's a long running task
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
@@ -70,6 +71,7 @@ import tachiyomi.presentation.widget.WidgetManager
|
|||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.InjektScope
|
import uy.kohesive.injekt.api.InjektScope
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
|
import kotlin.concurrent.thread
|
||||||
import mihon.icons.materialsymbols.R as MaterialSymbolsR
|
import mihon.icons.materialsymbols.R as MaterialSymbolsR
|
||||||
|
|
||||||
class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory, GraphProvider<AppGraph> {
|
class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factory, GraphProvider<AppGraph> {
|
||||||
@@ -88,11 +90,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
|||||||
|
|
||||||
@Inject private lateinit var uiPreferences: UiPreferences
|
@Inject private lateinit var uiPreferences: UiPreferences
|
||||||
|
|
||||||
@Inject private lateinit var coverCache: CoverCache
|
// Lazy: only the image loader uses these, and process starts without UI (workers, receivers) never build it
|
||||||
|
@Inject private lateinit var coverCache: Lazy<CoverCache>
|
||||||
|
|
||||||
@Inject private lateinit var networkHelper: NetworkHelper
|
@Inject private lateinit var networkHelper: Lazy<NetworkHelper>
|
||||||
|
|
||||||
@Inject private lateinit var sourceManager: SourceManager
|
@Inject private lateinit var sourceManager: Lazy<SourceManager>
|
||||||
|
|
||||||
@Inject private lateinit var widgetManager: WidgetManager
|
@Inject private lateinit var widgetManager: WidgetManager
|
||||||
|
|
||||||
@@ -114,6 +117,12 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
|||||||
if (packageName != process) WebView.setDataDirectorySuffix(process)
|
if (packageName != process) WebView.setDataDirectorySuffix(process)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The graph and the code after it read the default preferences straight away. Loading them takes a disk
|
||||||
|
// read, so start it on another thread while the graph is built; the first read then waits less or not at all
|
||||||
|
thread(name = "PreferencesPreload") { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||||
|
// The first use of Dispatchers.Main finds its factory with a ServiceLoader, which reads the APK
|
||||||
|
thread(name = "MainDispatcherPreload") { Dispatchers.Main }
|
||||||
|
|
||||||
Injekt = InjektScope(MetroInjektRegistrar(application = this, graphProvider = this))
|
Injekt = InjektScope(MetroInjektRegistrar(application = this, graphProvider = this))
|
||||||
graph.inject(this)
|
graph.inject(this)
|
||||||
|
|
||||||
@@ -205,7 +214,9 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
|||||||
|
|
||||||
override fun newImageLoader(context: Context): ImageLoader {
|
override fun newImageLoader(context: Context): ImageLoader {
|
||||||
return ImageLoader.Builder(this).apply {
|
return ImageLoader.Builder(this).apply {
|
||||||
val callFactoryLazy = lazy { networkHelper.client }
|
val callFactoryLazy = lazy { networkHelper.value.client }
|
||||||
|
val coverCache = coverCache.value
|
||||||
|
val sourceManager = sourceManager.value
|
||||||
components {
|
components {
|
||||||
// NetworkFetcher.Factory
|
// NetworkFetcher.Factory
|
||||||
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
|
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.util.system.cancelNotification
|
|||||||
import eu.kanade.tachiyomi.util.system.isRunning
|
import eu.kanade.tachiyomi.util.system.isRunning
|
||||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.AppGraph
|
import mihon.app.di.AppGraph
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
@@ -53,7 +55,7 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam
|
|||||||
if (isAutoBackup && BackupRestoreWorker.isRunning(context.workManager)) return Result.retry()
|
if (isAutoBackup && BackupRestoreWorker.isRunning(context.workManager)) return Result.retry()
|
||||||
|
|
||||||
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
|
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
|
||||||
?: getAutomaticBackupLocation()
|
?: withContext(Dispatchers.IO) { getAutomaticBackupLocation() }
|
||||||
?: return Result.failure()
|
?: return Result.failure()
|
||||||
|
|
||||||
setForegroundSafely()
|
setForegroundSafely()
|
||||||
@@ -62,7 +64,10 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam
|
|||||||
?: BackupOptions()
|
?: BackupOptions()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val location = backupCreatorFactory.create(isAutoBackup = isAutoBackup).backup(uri, options)
|
val location =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
backupCreatorFactory.create(isAutoBackup = isAutoBackup).backup(uri, options)
|
||||||
|
}
|
||||||
if (!isAutoBackup) {
|
if (!isAutoBackup) {
|
||||||
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
|
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
|
||||||
}
|
}
|
||||||
@@ -93,7 +98,7 @@ class BackupCreateWorker(private val context: Context, workerParams: WorkerParam
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun isManualJobRunning(context: Context): Boolean {
|
suspend fun isManualJobRunning(context: Context): Boolean {
|
||||||
return context.workManager.isRunning(TAG_MANUAL)
|
return context.workManager.isRunning(TAG_MANUAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import eu.kanade.tachiyomi.util.system.cancelNotification
|
|||||||
import eu.kanade.tachiyomi.util.system.isRunning
|
import eu.kanade.tachiyomi.util.system.isRunning
|
||||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.AppGraph
|
import mihon.app.di.AppGraph
|
||||||
import mihon.core.metro.metroGraph
|
import mihon.core.metro.metroGraph
|
||||||
@@ -50,7 +52,9 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
setForegroundSafely()
|
setForegroundSafely()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options)
|
backupRestorerFactory.create(notifier = notifier, isSync = isSync).restore(uri, options)
|
||||||
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
@@ -79,7 +83,7 @@ class BackupRestoreWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun isRunning(workManager: WorkManager): Boolean {
|
suspend fun isRunning(workManager: WorkManager): Boolean {
|
||||||
return workManager.isRunning(TAG)
|
return workManager.isRunning(TAG)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,17 +36,20 @@ class ChapterCache(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
/** Cache class used for cache management. */
|
/** Cache class used for cache management. */
|
||||||
private val diskCache = DiskLruCache.open(
|
private val diskCache by lazy {
|
||||||
|
DiskLruCache.open(
|
||||||
File(context.cacheDir, "chapter_disk_cache"),
|
File(context.cacheDir, "chapter_disk_cache"),
|
||||||
PARAMETER_APP_VERSION,
|
PARAMETER_APP_VERSION,
|
||||||
PARAMETER_VALUE_COUNT,
|
PARAMETER_VALUE_COUNT,
|
||||||
PARAMETER_CACHE_SIZE,
|
PARAMETER_CACHE_SIZE,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns directory of cache.
|
* Returns directory of cache.
|
||||||
*/
|
*/
|
||||||
private val cacheDir: File = diskCache.directory
|
private val cacheDir: File
|
||||||
|
get() = diskCache.directory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory.
|
* Returns real size of directory.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import dev.zacsweers.metro.AppScope
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -20,7 +23,10 @@ import java.io.InputStream
|
|||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
class CoverCache(private val context: Context) {
|
class CoverCache(
|
||||||
|
@AppCoroutineScope private val scope: CoroutineScope,
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val COVERS_DIR = "covers"
|
private const val COVERS_DIR = "covers"
|
||||||
@@ -30,9 +36,28 @@ class CoverCache(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Cache directory used for cache management.
|
* Cache directory used for cache management.
|
||||||
*/
|
*/
|
||||||
private val cacheDir = getCacheDir(COVERS_DIR)
|
private val cacheDir by lazy { getCacheDir(COVERS_DIR) }
|
||||||
|
|
||||||
private val customCoverCacheDir = getCacheDir(CUSTOM_COVERS_DIR)
|
private val customCoverCacheDir by lazy { getCacheDir(CUSTOM_COVERS_DIR) }
|
||||||
|
|
||||||
|
private val customCoverNames = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// Deleted while the directory was being listed, so the listing may still contain them
|
||||||
|
private val deletedWhileIndexing = mutableSetOf<String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var customCoversIndexed = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
val names = customCoverCacheDir.list().orEmpty()
|
||||||
|
synchronized(customCoverNames) {
|
||||||
|
names.filterNotTo(customCoverNames) { it in deletedWhileIndexing }
|
||||||
|
deletedWhileIndexing.clear()
|
||||||
|
customCoversIndexed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the cover from cache.
|
* Returns the cover from cache.
|
||||||
@@ -56,6 +81,12 @@ class CoverCache(private val context: Context) {
|
|||||||
return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(mangaId.toString()))
|
return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(mangaId.toString()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasCustomCover(mangaId: Long?): Boolean {
|
||||||
|
val file = getCustomCoverFile(mangaId)
|
||||||
|
if (!customCoversIndexed) return file.exists()
|
||||||
|
return synchronized(customCoverNames) { file.name in customCoverNames }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the given stream as the manga's custom cover to cache.
|
* Saves the given stream as the manga's custom cover to cache.
|
||||||
*
|
*
|
||||||
@@ -65,9 +96,14 @@ class CoverCache(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
|
fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
|
||||||
getCustomCoverFile(manga.id).outputStream().use {
|
val file = getCustomCoverFile(manga.id)
|
||||||
|
file.outputStream().use {
|
||||||
inputStream.copyTo(it)
|
inputStream.copyTo(it)
|
||||||
}
|
}
|
||||||
|
synchronized(customCoverNames) {
|
||||||
|
customCoverNames.add(file.name)
|
||||||
|
deletedWhileIndexing.remove(file.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,9 +134,13 @@ class CoverCache(private val context: Context) {
|
|||||||
* @return whether the cover was deleted.
|
* @return whether the cover was deleted.
|
||||||
*/
|
*/
|
||||||
fun deleteCustomCover(mangaId: Long?): Boolean {
|
fun deleteCustomCover(mangaId: Long?): Boolean {
|
||||||
return getCustomCoverFile(mangaId).let {
|
val file = getCustomCoverFile(mangaId)
|
||||||
it.exists() && it.delete()
|
val deleted = file.exists() && file.delete()
|
||||||
|
synchronized(customCoverNames) {
|
||||||
|
customCoverNames.remove(file.name)
|
||||||
|
if (!customCoversIndexed) deletedWhileIndexing.add(file.name)
|
||||||
}
|
}
|
||||||
|
return deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCacheDir(dir: String): File {
|
private fun getCacheDir(dir: String): File {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class MangaCoverKeyer(
|
|||||||
private val coverCache: CoverCache,
|
private val coverCache: CoverCache,
|
||||||
) : Keyer<MangaCover> {
|
) : Keyer<MangaCover> {
|
||||||
override fun key(data: MangaCover, options: Options): String {
|
override fun key(data: MangaCover, options: Options): String {
|
||||||
return if (coverCache.getCustomCoverFile(data.mangaId).exists()) {
|
return if (coverCache.hasCustomCover(data.mangaId)) {
|
||||||
"${data.mangaId};${data.lastModified}"
|
"${data.mangaId};${data.lastModified}"
|
||||||
} else {
|
} else {
|
||||||
"${data.url};${data.lastModified}"
|
"${data.url};${data.lastModified}"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.zacsweers.metro.SingleIn
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.onStart
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -43,8 +45,6 @@ import logcat.LogPriority
|
|||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.storage.extension
|
import tachiyomi.core.common.storage.extension
|
||||||
import tachiyomi.core.common.storage.nameWithoutExtension
|
import tachiyomi.core.common.storage.nameWithoutExtension
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -99,11 +99,13 @@ class DownloadCache(
|
|||||||
get() = AtomicFile(File(context.cacheDir, "dl_index_cache_v3"))
|
get() = AtomicFile(File(context.cacheDir, "dl_index_cache_v3"))
|
||||||
|
|
||||||
private val rootDownloadsDirMutex = Mutex()
|
private val rootDownloadsDirMutex = Mutex()
|
||||||
private var rootDownloadsDir = RootDirectory(storageManager.getDownloadsDirectory())
|
|
||||||
|
@Volatile
|
||||||
|
private var rootDownloadsDir = RootDirectory(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Attempt to read cache file
|
// Attempt to read cache file
|
||||||
initJob = scope.launchIO {
|
initJob = scope.launch(Dispatchers.IO) {
|
||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
try {
|
try {
|
||||||
if (diskCacheFile.baseFile.exists()) {
|
if (diskCacheFile.baseFile.exists()) {
|
||||||
@@ -252,11 +254,8 @@ class DownloadCache(
|
|||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
||||||
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
|
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
|
||||||
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
|
mangaDir.chapterDirs -=
|
||||||
if (it in mangaDir.chapterDirs) {
|
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).toSet()
|
||||||
mangaDir.chapterDirs -= it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChanges()
|
notifyChanges()
|
||||||
@@ -272,13 +271,9 @@ class DownloadCache(
|
|||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
||||||
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
|
val mangaDir = sourceDir.mangaDirs[provider.getMangaDirName(manga.title)] ?: return
|
||||||
chapters.forEach { chapter ->
|
mangaDir.chapterDirs -= chapters
|
||||||
provider.getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).forEach {
|
.flatMap { provider.getValidChapterDirNames(it.name, it.scanlator, it.url) }
|
||||||
if (it in mangaDir.chapterDirs) {
|
.toSet()
|
||||||
mangaDir.chapterDirs -= it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChanges()
|
notifyChanges()
|
||||||
@@ -312,7 +307,7 @@ class DownloadCache(
|
|||||||
rootDownloadsDirMutex.withLock {
|
rootDownloadsDirMutex.withLock {
|
||||||
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
||||||
val oldMangaDirName = provider.getMangaDirName(manga.title)
|
val oldMangaDirName = provider.getMangaDirName(manga.title)
|
||||||
var oldChapterDirs: MutableSet<String>? = null
|
var oldChapterDirs: Set<String>? = null
|
||||||
// Save the old name's cached chapter dirs
|
// Save the old name's cached chapter dirs
|
||||||
if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) {
|
if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) {
|
||||||
oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs
|
oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs
|
||||||
@@ -347,22 +342,22 @@ class DownloadCache(
|
|||||||
fun invalidateCache() {
|
fun invalidateCache() {
|
||||||
lastRenew = 0L
|
lastRenew = 0L
|
||||||
renewalJob?.cancel()
|
renewalJob?.cancel()
|
||||||
diskCacheFile.delete()
|
renewCache(deleteDiskCache = true)
|
||||||
renewCache()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renews the downloads cache.
|
* Renews the downloads cache.
|
||||||
*/
|
*/
|
||||||
private fun renewCache() {
|
private fun renewCache(deleteDiskCache: Boolean = false) {
|
||||||
// Avoid renewing cache if in the process nor too often
|
// Avoid renewing cache if in the process nor too often
|
||||||
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
|
if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renewalJob = scope.launchIO {
|
renewalJob = scope.launch(Dispatchers.IO) {
|
||||||
// The disk index is older than this scan and would replace it if read afterwards
|
// The disk index is older than this scan and would replace it if read afterwards
|
||||||
initJob.join()
|
initJob.join()
|
||||||
|
if (deleteDiskCache) diskCacheFile.delete()
|
||||||
|
|
||||||
if (lastRenew == 0L) {
|
if (lastRenew == 0L) {
|
||||||
_isInitializing.emit(true)
|
_isInitializing.emit(true)
|
||||||
@@ -407,7 +402,7 @@ class DownloadCache(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toMutableSet()
|
.toSet()
|
||||||
|
|
||||||
mangaDir.chapterDirs = chapterDirs
|
mangaDir.chapterDirs = chapterDirs
|
||||||
}
|
}
|
||||||
@@ -424,7 +419,8 @@ class DownloadCache(
|
|||||||
if (exception != null && exception !is CancellationException) {
|
if (exception != null && exception !is CancellationException) {
|
||||||
logcat(LogPriority.ERROR, exception) { "DownloadCache: failed to create cache" }
|
logcat(LogPriority.ERROR, exception) { "DownloadCache: failed to create cache" }
|
||||||
}
|
}
|
||||||
lastRenew = System.currentTimeMillis()
|
// A cancelled renewal (e.g. from invalidateCache) must not hold off the one replacing it
|
||||||
|
if (exception !is CancellationException) lastRenew = System.currentTimeMillis()
|
||||||
notifyChanges()
|
notifyChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,16 +434,14 @@ class DownloadCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyChanges() {
|
private fun notifyChanges() {
|
||||||
scope.launchNonCancellable {
|
_changes.trySend(Unit)
|
||||||
_changes.send(Unit)
|
|
||||||
}
|
|
||||||
updateDiskCache()
|
updateDiskCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var updateDiskCacheJob: Job? = null
|
private var updateDiskCacheJob: Job? = null
|
||||||
private fun updateDiskCache() {
|
private fun updateDiskCache() {
|
||||||
updateDiskCacheJob?.cancel()
|
updateDiskCacheJob?.cancel()
|
||||||
updateDiskCacheJob = scope.launchIO {
|
updateDiskCacheJob = scope.launch(Dispatchers.IO) {
|
||||||
delay(1.seconds)
|
delay(1.seconds)
|
||||||
val bytes = rootDownloadsDirMutex.withLock {
|
val bytes = rootDownloadsDirMutex.withLock {
|
||||||
ProtoBuf.encodeToByteArray(rootDownloadsDir)
|
ProtoBuf.encodeToByteArray(rootDownloadsDir)
|
||||||
@@ -473,6 +467,7 @@ class DownloadCache(
|
|||||||
private class RootDirectory(
|
private class RootDirectory(
|
||||||
@Serializable(with = UniFileAsStringSerializer::class)
|
@Serializable(with = UniFileAsStringSerializer::class)
|
||||||
val dir: UniFile?,
|
val dir: UniFile?,
|
||||||
|
@Volatile
|
||||||
var sourceDirs: Map<Long, SourceDirectory> = mapOf(),
|
var sourceDirs: Map<Long, SourceDirectory> = mapOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -483,6 +478,7 @@ private class RootDirectory(
|
|||||||
private class SourceDirectory(
|
private class SourceDirectory(
|
||||||
@Serializable(with = UniFileAsStringSerializer::class)
|
@Serializable(with = UniFileAsStringSerializer::class)
|
||||||
val dir: UniFile?,
|
val dir: UniFile?,
|
||||||
|
@Volatile
|
||||||
var mangaDirs: Map<String, MangaDirectory> = mapOf(),
|
var mangaDirs: Map<String, MangaDirectory> = mapOf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,7 +489,9 @@ private class SourceDirectory(
|
|||||||
private class MangaDirectory(
|
private class MangaDirectory(
|
||||||
@Serializable(with = UniFileAsStringSerializer::class)
|
@Serializable(with = UniFileAsStringSerializer::class)
|
||||||
val dir: UniFile?,
|
val dir: UniFile?,
|
||||||
var chapterDirs: MutableSet<String> = mutableSetOf(),
|
// Replaced rather than changed in place, so the lock-free lookups always read a consistent set
|
||||||
|
@Volatile
|
||||||
|
var chapterDirs: Set<String> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private object UniFileAsStringSerializer : KSerializer<UniFile?> {
|
private object UniFileAsStringSerializer : KSerializer<UniFile?> {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
@@ -17,12 +17,11 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.storage.extension
|
import tachiyomi.core.common.storage.extension
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.system.ImageUtil
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
@@ -42,7 +41,6 @@ import tachiyomi.i18n.MR
|
|||||||
@Inject
|
@Inject
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
class DownloadManager(
|
class DownloadManager(
|
||||||
@AppCoroutineScope private val scope: CoroutineScope,
|
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val provider: DownloadProvider,
|
private val provider: DownloadProvider,
|
||||||
private val cache: DownloadCache,
|
private val cache: DownloadCache,
|
||||||
@@ -73,15 +71,14 @@ class DownloadManager(
|
|||||||
/**
|
/**
|
||||||
* Tells the downloader to pause downloads.
|
* Tells the downloader to pause downloads.
|
||||||
*/
|
*/
|
||||||
fun pauseDownloads() {
|
suspend fun pauseDownloads() {
|
||||||
downloader.pause()
|
downloader.pause()
|
||||||
downloader.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empties the download queue.
|
* Empties the download queue.
|
||||||
*/
|
*/
|
||||||
fun clearQueue() {
|
suspend fun clearQueue() {
|
||||||
downloader.clearQueue()
|
downloader.clearQueue()
|
||||||
downloader.stop()
|
downloader.stop()
|
||||||
}
|
}
|
||||||
@@ -103,14 +100,12 @@ class DownloadManager(
|
|||||||
return queueState.value.associateBy { it.chapter.id }
|
return queueState.value.associateBy { it.chapter.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDownloadNow(chapterId: Long) {
|
suspend fun startDownloadNow(chapterId: Long) {
|
||||||
val existingDownload = getQueuedDownloadOrNull(chapterId)
|
|
||||||
// If not in queue try to start a new download
|
// If not in queue try to start a new download
|
||||||
val toAdd = existingDownload ?: runBlocking { downloadFromChapterId(chapterId) } ?: return
|
val toAdd = getQueuedDownloadOrNull(chapterId) ?: downloadFromChapterId(chapterId) ?: return
|
||||||
queueState.value.toMutableList().apply {
|
downloader.updateQueue { queue ->
|
||||||
existingDownload?.let { remove(it) }
|
val (existing, others) = queue.partition { it.chapter.id == chapterId }
|
||||||
add(0, toAdd)
|
listOf(existing.firstOrNull() ?: toAdd) + others
|
||||||
reorderQueue(this)
|
|
||||||
}
|
}
|
||||||
startDownloads()
|
startDownloads()
|
||||||
}
|
}
|
||||||
@@ -126,10 +121,15 @@ class DownloadManager(
|
|||||||
/**
|
/**
|
||||||
* Reorders the download queue.
|
* Reorders the download queue.
|
||||||
*
|
*
|
||||||
* @param downloads value to set the download queue to
|
* @param downloads the queue in its new order. Downloads that left the queue since are dropped, and ones
|
||||||
|
* that joined it are kept at the end.
|
||||||
*/
|
*/
|
||||||
fun reorderQueue(downloads: List<Download>) {
|
suspend fun reorderQueue(downloads: List<Download>) {
|
||||||
downloader.updateQueue(downloads)
|
downloader.updateQueue { queue ->
|
||||||
|
val queueById = queue.associateBy { it.chapter.id }
|
||||||
|
val orderedIds = downloads.mapTo(HashSet()) { it.chapter.id }
|
||||||
|
downloads.mapNotNull { queueById[it.chapter.id] } + queue.filter { it.chapter.id !in orderedIds }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,11 +150,11 @@ class DownloadManager(
|
|||||||
*
|
*
|
||||||
* @param downloads the list of downloads to enqueue.
|
* @param downloads the list of downloads to enqueue.
|
||||||
*/
|
*/
|
||||||
fun addDownloadsToStartOfQueue(downloads: List<Download>) {
|
suspend fun addDownloadsToStartOfQueue(downloads: List<Download>) {
|
||||||
if (downloads.isEmpty()) return
|
if (downloads.isEmpty()) return
|
||||||
queueState.value.toMutableList().apply {
|
downloader.updateQueue { queue ->
|
||||||
addAll(0, downloads)
|
val chapterIds = downloads.mapTo(HashSet()) { it.chapter.id }
|
||||||
reorderQueue(this)
|
downloads + queue.filter { it.chapter.id !in chapterIds }
|
||||||
}
|
}
|
||||||
startDownloads()
|
startDownloads()
|
||||||
}
|
}
|
||||||
@@ -167,10 +167,12 @@ class DownloadManager(
|
|||||||
* @param chapter the downloaded chapter.
|
* @param chapter the downloaded chapter.
|
||||||
* @return the list of pages from the chapter.
|
* @return the list of pages from the chapter.
|
||||||
*/
|
*/
|
||||||
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> {
|
suspend fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> {
|
||||||
val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source)
|
val chapterDir = provider.findChapterDir(chapter.name, chapter.scanlator, chapter.url, manga.title, source)
|
||||||
val files = chapterDir?.listFiles().orEmpty()
|
val files = withContext(Dispatchers.IO) {
|
||||||
|
chapterDir?.listFiles().orEmpty()
|
||||||
.filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } }
|
.filter { it.isFile && ImageUtil.isImage(it.name) { it.openInputStream() } }
|
||||||
|
}
|
||||||
|
|
||||||
if (files.isEmpty()) {
|
if (files.isEmpty()) {
|
||||||
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
|
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
|
||||||
@@ -218,7 +220,7 @@ class DownloadManager(
|
|||||||
* @param mangaTitle the title of the manga to query.
|
* @param mangaTitle the title of the manga to query.
|
||||||
* @param source the source of the chapter.
|
* @param source the source of the chapter.
|
||||||
*/
|
*/
|
||||||
fun isChapterDownloadedOnDisk(
|
suspend fun isChapterDownloadedOnDisk(
|
||||||
chapterName: String,
|
chapterName: String,
|
||||||
chapterScanlator: String?,
|
chapterScanlator: String?,
|
||||||
chapterUrl: String,
|
chapterUrl: String,
|
||||||
@@ -244,8 +246,8 @@ class DownloadManager(
|
|||||||
return cache.getDownloadCount(manga)
|
return cache.getDownloadCount(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelQueuedDownloads(downloads: List<Download>) {
|
suspend fun cancelQueuedDownloads(downloads: List<Download>) {
|
||||||
removeFromDownloadQueue(downloads.map { it.chapter })
|
downloader.dequeue(downloads.map { it.chapter })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,14 +257,14 @@ class DownloadManager(
|
|||||||
* @param manga the manga of the chapters.
|
* @param manga the manga of the chapters.
|
||||||
* @param source the source of the chapters.
|
* @param source the source of the chapters.
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
suspend fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
||||||
scope.launchIO {
|
withContext(Dispatchers.IO) {
|
||||||
val filteredChapters = getChaptersToDelete(chapters, manga)
|
val filteredChapters = getChaptersToDelete(chapters, manga)
|
||||||
if (filteredChapters.isEmpty()) {
|
if (filteredChapters.isEmpty()) {
|
||||||
return@launchIO
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromDownloadQueue(filteredChapters)
|
downloader.dequeue(filteredChapters)
|
||||||
|
|
||||||
val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source)
|
val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source)
|
||||||
chapterDirs.forEach { it.delete() }
|
chapterDirs.forEach { it.delete() }
|
||||||
@@ -282,8 +284,8 @@ class DownloadManager(
|
|||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
* @param removeQueued whether to also remove queued downloads.
|
* @param removeQueued whether to also remove queued downloads.
|
||||||
*/
|
*/
|
||||||
fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) {
|
suspend fun deleteManga(manga: Manga, source: Source, removeQueued: Boolean = true) {
|
||||||
scope.launchIO {
|
withContext(Dispatchers.IO) {
|
||||||
if (removeQueued) {
|
if (removeQueued) {
|
||||||
downloader.removeFromQueue(manga)
|
downloader.removeFromQueue(manga)
|
||||||
}
|
}
|
||||||
@@ -299,23 +301,6 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeFromDownloadQueue(chapters: List<Chapter>) {
|
|
||||||
val wasRunning = downloader.isRunning
|
|
||||||
if (wasRunning) {
|
|
||||||
downloader.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
downloader.removeFromQueue(chapters)
|
|
||||||
|
|
||||||
if (wasRunning) {
|
|
||||||
if (queueState.value.isEmpty()) {
|
|
||||||
downloader.stop()
|
|
||||||
} else if (queueState.value.isNotEmpty()) {
|
|
||||||
downloader.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a list of chapters to be deleted later.
|
* Adds a list of chapters to be deleted later.
|
||||||
*
|
*
|
||||||
@@ -343,18 +328,18 @@ class DownloadManager(
|
|||||||
* @param oldSource the old source.
|
* @param oldSource the old source.
|
||||||
* @param newSource the new source.
|
* @param newSource the new source.
|
||||||
*/
|
*/
|
||||||
fun renameSource(oldSource: Source, newSource: Source) {
|
suspend fun renameSource(oldSource: Source, newSource: Source) = withContext(Dispatchers.IO) {
|
||||||
val oldFolder = provider.findSourceDir(oldSource) ?: return
|
val oldFolder = provider.findSourceDir(oldSource) ?: return@withContext
|
||||||
val newName = provider.getSourceDirName(newSource)
|
val newName = provider.getSourceDirName(newSource)
|
||||||
|
|
||||||
if (oldFolder.name == newName) return
|
if (oldFolder.name == newName) return@withContext
|
||||||
|
|
||||||
val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true)
|
val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true)
|
||||||
if (capitalizationChanged) {
|
if (capitalizationChanged) {
|
||||||
val tempName = newName + Downloader.TMP_DIR_SUFFIX
|
val tempName = newName + Downloader.TMP_DIR_SUFFIX
|
||||||
if (!oldFolder.renameTo(tempName)) {
|
if (!oldFolder.renameTo(tempName)) {
|
||||||
logcat(LogPriority.ERROR) { "Failed to rename source download folder: ${oldFolder.name}" }
|
logcat(LogPriority.ERROR) { "Failed to rename source download folder: ${oldFolder.name}" }
|
||||||
return
|
return@withContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +354,12 @@ class DownloadManager(
|
|||||||
* @param manga the manga
|
* @param manga the manga
|
||||||
* @param newTitle the new manga title.
|
* @param newTitle the new manga title.
|
||||||
*/
|
*/
|
||||||
suspend fun renameManga(manga: Manga, newTitle: String) {
|
suspend fun renameManga(manga: Manga, newTitle: String) = withContext(Dispatchers.IO) {
|
||||||
val source = sourceManager.getOrStub(manga.source)
|
val source = sourceManager.getOrStub(manga.source)
|
||||||
val oldFolder = provider.findMangaDir(manga.title, source) ?: return
|
val oldFolder = provider.findMangaDir(manga.title, source) ?: return@withContext
|
||||||
val newName = provider.getMangaDirName(newTitle)
|
val newName = provider.getMangaDirName(newTitle)
|
||||||
|
|
||||||
if (oldFolder.name == newName) return
|
if (oldFolder.name == newName) return@withContext
|
||||||
|
|
||||||
// just to be safe, don't allow downloads for this manga while renaming it
|
// just to be safe, don't allow downloads for this manga while renaming it
|
||||||
downloader.removeFromQueue(manga)
|
downloader.removeFromQueue(manga)
|
||||||
@@ -384,7 +369,7 @@ class DownloadManager(
|
|||||||
val tempName = newName + Downloader.TMP_DIR_SUFFIX
|
val tempName = newName + Downloader.TMP_DIR_SUFFIX
|
||||||
if (!oldFolder.renameTo(tempName)) {
|
if (!oldFolder.renameTo(tempName)) {
|
||||||
logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" }
|
logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" }
|
||||||
return
|
return@withContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,24 +388,27 @@ class DownloadManager(
|
|||||||
* @param oldChapter the existing chapter with the old name.
|
* @param oldChapter the existing chapter with the old name.
|
||||||
* @param newChapter the target chapter with the new name.
|
* @param newChapter the target chapter with the new name.
|
||||||
*/
|
*/
|
||||||
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) {
|
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url)
|
val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator, oldChapter.url)
|
||||||
val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
|
val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
|
||||||
logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" }
|
logcat(LogPriority.ERROR, e) {
|
||||||
return
|
"Manga download folder doesn't exist. Skipping renaming after source sync"
|
||||||
|
}
|
||||||
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assume there's only 1 version of the chapter name formats present
|
// Assume there's only 1 version of the chapter name formats present
|
||||||
val oldDownload = oldNames.asSequence()
|
val oldDownload = oldNames.asSequence()
|
||||||
.mapNotNull { mangaDir.findFile(it) }
|
.mapNotNull { mangaDir.findFile(it) }
|
||||||
.firstOrNull() ?: return
|
.firstOrNull() ?: return@withContext
|
||||||
|
|
||||||
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
|
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
|
||||||
if (oldDownload.isFile && oldDownload.extension == "cbz") {
|
if (oldDownload.isFile && oldDownload.extension == "cbz") {
|
||||||
newName += ".cbz"
|
newName += ".cbz"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldDownload.name == newName) return
|
if (oldDownload.name == newName) return@withContext
|
||||||
|
|
||||||
if (oldDownload.renameTo(newName)) {
|
if (oldDownload.renameTo(newName)) {
|
||||||
cache.removeChapter(oldChapter, manga)
|
cache.removeChapter(oldChapter, manga)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class DownloadPendingDeleter(
|
|||||||
/**
|
/**
|
||||||
* Preferences used to store the list of chapters to delete.
|
* Preferences used to store the list of chapters to delete.
|
||||||
*/
|
*/
|
||||||
private val preferences = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
|
private val preferences by lazy { context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last added chapter, used to avoid decoding from the preference too often.
|
* Last added chapter, used to avoid decoding from the preference too often.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import dev.zacsweers.metro.SingleIn
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.util.lang.Hash.md5
|
import eu.kanade.tachiyomi.util.lang.Hash.md5
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.storage.displayablePath
|
import tachiyomi.core.common.storage.displayablePath
|
||||||
@@ -42,7 +44,10 @@ class DownloadProvider(
|
|||||||
* @param mangaTitle the title of the manga to query.
|
* @param mangaTitle the title of the manga to query.
|
||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
internal fun getMangaDir(mangaTitle: String, source: Source): Result<UniFile> {
|
internal suspend fun getMangaDir(mangaTitle: String, source: Source): Result<UniFile> =
|
||||||
|
withContext(Dispatchers.IO) { createMangaDir(mangaTitle, source) }
|
||||||
|
|
||||||
|
private fun createMangaDir(mangaTitle: String, source: Source): Result<UniFile> {
|
||||||
val downloadsDir = downloadsDir
|
val downloadsDir = downloadsDir
|
||||||
if (downloadsDir == null) {
|
if (downloadsDir == null) {
|
||||||
logcat(LogPriority.ERROR) { "Failed to create download directory" }
|
logcat(LogPriority.ERROR) { "Failed to create download directory" }
|
||||||
@@ -79,8 +84,8 @@ class DownloadProvider(
|
|||||||
*
|
*
|
||||||
* @param source the source to query.
|
* @param source the source to query.
|
||||||
*/
|
*/
|
||||||
fun findSourceDir(source: Source): UniFile? {
|
suspend fun findSourceDir(source: Source): UniFile? = withContext(Dispatchers.IO) {
|
||||||
return downloadsDir?.findFile(getSourceDirName(source))
|
downloadsDir?.findFile(getSourceDirName(source))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,9 +94,8 @@ class DownloadProvider(
|
|||||||
* @param mangaTitle the title of the manga to query.
|
* @param mangaTitle the title of the manga to query.
|
||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
fun findMangaDir(mangaTitle: String, source: Source): UniFile? {
|
suspend fun findMangaDir(mangaTitle: String, source: Source): UniFile? = withContext(Dispatchers.IO) {
|
||||||
val sourceDir = findSourceDir(source)
|
findSourceDir(source)?.findFile(getMangaDirName(mangaTitle))
|
||||||
return sourceDir?.findFile(getMangaDirName(mangaTitle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,15 +106,15 @@ class DownloadProvider(
|
|||||||
* @param mangaTitle the title of the manga to query.
|
* @param mangaTitle the title of the manga to query.
|
||||||
* @param source the source of the chapter.
|
* @param source the source of the chapter.
|
||||||
*/
|
*/
|
||||||
fun findChapterDir(
|
suspend fun findChapterDir(
|
||||||
chapterName: String,
|
chapterName: String,
|
||||||
chapterScanlator: String?,
|
chapterScanlator: String?,
|
||||||
chapterUrl: String,
|
chapterUrl: String,
|
||||||
mangaTitle: String,
|
mangaTitle: String,
|
||||||
source: Source,
|
source: Source,
|
||||||
): UniFile? {
|
): UniFile? = withContext(Dispatchers.IO) {
|
||||||
val mangaDir = findMangaDir(mangaTitle, source)
|
val mangaDir = findMangaDir(mangaTitle, source)
|
||||||
return getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
|
getValidChapterDirNames(chapterName, chapterScanlator, chapterUrl).asSequence()
|
||||||
.mapNotNull { mangaDir?.findFile(it) }
|
.mapNotNull { mangaDir?.findFile(it) }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
}
|
}
|
||||||
@@ -122,9 +126,13 @@ class DownloadProvider(
|
|||||||
* @param manga the manga of the chapter.
|
* @param manga the manga of the chapter.
|
||||||
* @param source the source of the chapter.
|
* @param source the source of the chapter.
|
||||||
*/
|
*/
|
||||||
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
|
suspend fun findChapterDirs(
|
||||||
val mangaDir = findMangaDir(manga.title, source) ?: return null to emptyList()
|
chapters: List<Chapter>,
|
||||||
return mangaDir to chapters.mapNotNull { chapter ->
|
manga: Manga,
|
||||||
|
source: Source,
|
||||||
|
): Pair<UniFile?, List<UniFile>> = withContext(Dispatchers.IO) {
|
||||||
|
val mangaDir = findMangaDir(manga.title, source) ?: return@withContext null to emptyList()
|
||||||
|
mangaDir to chapters.mapNotNull { chapter ->
|
||||||
getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
|
getValidChapterDirNames(chapter.name, chapter.scanlator, chapter.url).asSequence()
|
||||||
.mapNotNull { mangaDir.findFile(it) }
|
.mapNotNull { mangaDir.findFile(it) }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class DownloadStore(
|
|||||||
/**
|
/**
|
||||||
* Preference file where active downloads are stored.
|
* Preference file where active downloads are stored.
|
||||||
*/
|
*/
|
||||||
private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE)
|
private val preferences by lazy { context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counter used to keep the queue order.
|
* Counter used to keep the queue order.
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import eu.kanade.tachiyomi.data.notification.Notifications
|
|||||||
import eu.kanade.tachiyomi.util.system.activeNetworkState
|
import eu.kanade.tachiyomi.util.system.activeNetworkState
|
||||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.app.di.AppGraph
|
import mihon.app.di.AppGraph
|
||||||
import mihon.core.metro.metroGraph
|
import mihon.core.metro.metroGraph
|
||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
@@ -74,7 +76,10 @@ class DownloadWorker(context: Context, workerParams: WorkerParameters) : Corouti
|
|||||||
networkIssue = networkIssue()
|
networkIssue = networkIssue()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (downloader.isRunning && (networkIssue != null || isStopped)) downloader.stop(networkIssue)
|
// Stopped by WorkManager means already canceled, and taking the lock would then throw instead of stopping
|
||||||
|
if (downloader.isRunning && (networkIssue != null || isStopped)) {
|
||||||
|
withContext(NonCancellable) { downloader.stop(networkIssue) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.success()
|
return Result.success()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import eu.kanade.tachiyomi.util.storage.saveTo
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
@@ -39,9 +40,12 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.archive.ZipWriter
|
import mihon.core.archive.ZipWriter
|
||||||
@@ -49,7 +53,6 @@ import mihon.core.metro.AppCoroutineScope
|
|||||||
import nl.adaptivity.xmlutil.serialization.XML
|
import nl.adaptivity.xmlutil.serialization.XML
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.system.ImageUtil
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
|
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
|
||||||
@@ -93,7 +96,12 @@ class Downloader(
|
|||||||
private val _queueState = MutableStateFlow<List<Download>>(emptyList())
|
private val _queueState = MutableStateFlow<List<Download>>(emptyList())
|
||||||
val queueState = _queueState.asStateFlow()
|
val queueState = _queueState.asStateFlow()
|
||||||
|
|
||||||
|
// Held by every start, stop and queue edit, which come from the main thread, the download worker and the
|
||||||
|
// deletion jobs, so a check of isRunning can't interleave with another caller's start or stop
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
// Kept when canceled, so the next job can wait for it to finish
|
// Kept when canceled, so the next job can wait for it to finish
|
||||||
|
@Volatile
|
||||||
private var downloaderJob: Job? = null
|
private var downloaderJob: Job? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,8 +117,8 @@ class Downloader(
|
|||||||
var isPaused: Boolean = false
|
var isPaused: Boolean = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
|
||||||
addAllToQueue(store.restore())
|
addAllToQueue(withContext(Dispatchers.IO) { store.restore() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +128,42 @@ class Downloader(
|
|||||||
*
|
*
|
||||||
* @return true if the downloader is started, false otherwise.
|
* @return true if the downloader is started, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun start(): Boolean {
|
suspend fun start(): Boolean = lock.withLock { startLocked() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the downloader.
|
||||||
|
*/
|
||||||
|
suspend fun stop(reason: String? = null) = lock.withLock { stopLocked(reason) }
|
||||||
|
|
||||||
|
// Download jobs check these under the lock: by the time they get it, chapters may have been queued or the
|
||||||
|
// downloader stopped and started again, and that newer run must not be stopped
|
||||||
|
private suspend fun stopIfFinished(owner: Job) = lock.withLock {
|
||||||
|
if (downloaderJob === owner && owner.isActive && areAllDownloadsFinished()) stopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stopIfCurrent(owner: Job) = lock.withLock {
|
||||||
|
if (downloaderJob === owner && owner.isActive) stopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses and stops the downloader, keeping the queue for a later start
|
||||||
|
*/
|
||||||
|
suspend fun pause() = lock.withLock {
|
||||||
|
pauseLocked()
|
||||||
|
stopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes everything from the queue.
|
||||||
|
*/
|
||||||
|
suspend fun clearQueue() = lock.withLock {
|
||||||
|
cancelDownloaderJob()
|
||||||
|
|
||||||
|
internalClearQueue()
|
||||||
|
notifier.dismissProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocked(): Boolean {
|
||||||
if (isRunning || queueState.value.isEmpty()) {
|
if (isRunning || queueState.value.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -135,10 +178,7 @@ class Downloader(
|
|||||||
return pending.isNotEmpty()
|
return pending.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun stopLocked(reason: String? = null) {
|
||||||
* Stops the downloader.
|
|
||||||
*/
|
|
||||||
fun stop(reason: String? = null) {
|
|
||||||
cancelDownloaderJob()
|
cancelDownloaderJob()
|
||||||
queueState.value
|
queueState.value
|
||||||
.filter { it.status == Download.State.DOWNLOADING }
|
.filter { it.status == Download.State.DOWNLOADING }
|
||||||
@@ -158,10 +198,7 @@ class Downloader(
|
|||||||
isPaused = false
|
isPaused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun pauseLocked() {
|
||||||
* Pauses the downloader
|
|
||||||
*/
|
|
||||||
fun pause() {
|
|
||||||
cancelDownloaderJob()
|
cancelDownloaderJob()
|
||||||
queueState.value
|
queueState.value
|
||||||
.filter { it.status == Download.State.DOWNLOADING }
|
.filter { it.status == Download.State.DOWNLOADING }
|
||||||
@@ -169,16 +206,6 @@ class Downloader(
|
|||||||
isPaused = true
|
isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes everything from the queue.
|
|
||||||
*/
|
|
||||||
fun clearQueue() {
|
|
||||||
cancelDownloaderJob()
|
|
||||||
|
|
||||||
internalClearQueue()
|
|
||||||
notifier.dismissProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares the subscriptions to start downloading.
|
* Prepares the subscriptions to start downloading.
|
||||||
*/
|
*/
|
||||||
@@ -190,6 +217,7 @@ class Downloader(
|
|||||||
// Page writes block, so a canceled job can still be writing the pages this one would start on. Not
|
// Page writes block, so a canceled job can still be writing the pages this one would start on. Not
|
||||||
// cancelable, so a job canceled while waiting still finishes after the one before it.
|
// cancelable, so a job canceled while waiting still finishes after the one before it.
|
||||||
withContext(NonCancellable) { previousJob?.join() }
|
withContext(NonCancellable) { previousJob?.join() }
|
||||||
|
val owner = coroutineContext.job
|
||||||
|
|
||||||
val activeDownloadsFlow = combine(
|
val activeDownloadsFlow = combine(
|
||||||
queueState,
|
queueState,
|
||||||
@@ -238,7 +266,7 @@ class Downloader(
|
|||||||
|
|
||||||
val sourcesToStart = activeSources.filter { it !in sourceJobs }
|
val sourcesToStart = activeSources.filter { it !in sourceJobs }
|
||||||
sourcesToStart.forEach { source ->
|
sourcesToStart.forEach { source ->
|
||||||
sourceJobs[source] = launchSourceJob(source, downloadJobs)
|
sourceJobs[source] = launchSourceJob(source, downloadJobs, owner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +281,8 @@ class Downloader(
|
|||||||
private fun CoroutineScope.launchSourceJob(
|
private fun CoroutineScope.launchSourceJob(
|
||||||
source: HttpSource,
|
source: HttpSource,
|
||||||
downloadJobs: MutableMap<Download, Job>,
|
downloadJobs: MutableMap<Download, Job>,
|
||||||
) = launchIO {
|
owner: Job,
|
||||||
|
) = launch(Dispatchers.IO) {
|
||||||
val slots = Semaphore(downloadPreferences.parallelPageLimit.get())
|
val slots = Semaphore(downloadPreferences.parallelPageLimit.get())
|
||||||
while (true) {
|
while (true) {
|
||||||
// A canceled job can mark a chapter as downloading after it's been reset, so that counts as waiting too
|
// A canceled job can mark a chapter as downloading after it's been reset, so that counts as waiting too
|
||||||
@@ -268,9 +297,13 @@ class Downloader(
|
|||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.first()
|
.first()
|
||||||
val pagesStarted = CompletableDeferred<Unit>()
|
val pagesStarted = CompletableDeferred<Unit>()
|
||||||
val job = launchDownloadJob(download, slots, pagesStarted)
|
val job = launchDownloadJob(download, slots, pagesStarted, owner)
|
||||||
downloadJobs[download] = job
|
downloadJobs[download] = job
|
||||||
job.invokeOnCompletion { downloadJobs.remove(download, job) }
|
job.invokeOnCompletion {
|
||||||
|
downloadJobs.remove(download, job)
|
||||||
|
// A job canceled before it starts never reaches its finally block
|
||||||
|
pagesStarted.complete(Unit)
|
||||||
|
}
|
||||||
pagesStarted.await()
|
pagesStarted.await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,22 +312,24 @@ class Downloader(
|
|||||||
download: Download,
|
download: Download,
|
||||||
slots: Semaphore,
|
slots: Semaphore,
|
||||||
pagesStarted: CompletableDeferred<Unit>,
|
pagesStarted: CompletableDeferred<Unit>,
|
||||||
) = launchIO {
|
owner: Job,
|
||||||
|
) = launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
// Removed from the queue between being picked and starting
|
||||||
|
if (download !in queueState.value) return@launch
|
||||||
|
|
||||||
downloadChapter(download, slots, pagesStarted)
|
downloadChapter(download, slots, pagesStarted)
|
||||||
|
|
||||||
// Remove successful download from queue
|
// Remove successful download from queue
|
||||||
if (download.status == Download.State.DOWNLOADED) {
|
if (download.status == Download.State.DOWNLOADED) {
|
||||||
removeFromQueue(download)
|
removeFromQueue(download)
|
||||||
}
|
}
|
||||||
if (areAllDownloadsFinished()) {
|
stopIfFinished(owner)
|
||||||
stop()
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
notifier.onError(e.message)
|
notifier.onError(e.message)
|
||||||
stop()
|
stopIfCurrent(owner)
|
||||||
} finally {
|
} finally {
|
||||||
// Also when the chapter fails before its pages, so the source moves on to the next one
|
// Also when the chapter fails before its pages, so the source moves on to the next one
|
||||||
pagesStarted.complete(Unit)
|
pagesStarted.complete(Unit)
|
||||||
@@ -321,9 +356,11 @@ class Downloader(
|
|||||||
|
|
||||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return false
|
val source = sourceManager.get(manga.source) as? HttpSource ?: return false
|
||||||
val wasEmpty = queueState.value.isEmpty()
|
val wasEmpty = queueState.value.isEmpty()
|
||||||
val chaptersToQueue = chapters.asSequence()
|
val chaptersToQueue = withContext(Dispatchers.IO) {
|
||||||
|
chapters
|
||||||
// Filter out those already downloaded.
|
// Filter out those already downloaded.
|
||||||
.filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null }
|
.filter { provider.findChapterDir(it.name, it.scanlator, it.url, manga.title, source) == null }
|
||||||
|
.asSequence()
|
||||||
// Add chapters to queue from the start.
|
// Add chapters to queue from the start.
|
||||||
.sortedByDescending { it.sourceOrder }
|
.sortedByDescending { it.sourceOrder }
|
||||||
// Filter out those already enqueued.
|
// Filter out those already enqueued.
|
||||||
@@ -331,6 +368,7 @@ class Downloader(
|
|||||||
// Create a download for each one.
|
// Create a download for each one.
|
||||||
.map { Download(source, manga, it) }
|
.map { Download(source, manga, it) }
|
||||||
.toList()
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
if (chaptersToQueue.isNotEmpty()) {
|
if (chaptersToQueue.isNotEmpty()) {
|
||||||
addAllToQueue(chaptersToQueue)
|
addAllToQueue(chaptersToQueue)
|
||||||
@@ -725,12 +763,14 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun addAllToQueue(downloads: List<Download>) {
|
private fun addAllToQueue(downloads: List<Download>) {
|
||||||
_queueState.update {
|
_queueState.update { queue ->
|
||||||
downloads.forEach { download ->
|
// Callers filter off the queue's thread, so a chapter may have been queued in the meantime
|
||||||
|
val toAdd = downloads.filter { download -> queue.none { it.chapter.id == download.chapter.id } }
|
||||||
|
toAdd.forEach { download ->
|
||||||
download.status = Download.State.QUEUE
|
download.status = Download.State.QUEUE
|
||||||
}
|
}
|
||||||
store.addAll(downloads)
|
store.addAll(toAdd)
|
||||||
it + downloads
|
queue + toAdd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,12 +797,12 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromQueue(chapters: List<Chapter>) {
|
private fun removeFromQueue(chapters: List<Chapter>) {
|
||||||
val chapterIds = chapters.map { it.id }
|
val chapterIds = chapters.map { it.id }
|
||||||
removeFromQueueIf { it.chapter.id in chapterIds }
|
removeFromQueueIf { it.chapter.id in chapterIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromQueue(manga: Manga) {
|
suspend fun removeFromQueue(manga: Manga) = lock.withLock {
|
||||||
removeFromQueueIf { it.manga.id == manga.id }
|
removeFromQueueIf { it.manga.id == manga.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,21 +818,44 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateQueue(downloads: List<Download>) {
|
/**
|
||||||
val wasRunning = isRunning
|
* Replaces the queue with [transform] applied to the current queue. It runs under the lock, so downloads
|
||||||
|
* queued or removed meanwhile aren't lost to a stale copy of the queue.
|
||||||
if (downloads.isEmpty()) {
|
*/
|
||||||
clearQueue()
|
suspend fun updateQueue(transform: (List<Download>) -> List<Download>) = editQueue {
|
||||||
stop()
|
// One update, since chapters are queued without the lock
|
||||||
return
|
_queueState.update { queue ->
|
||||||
|
val downloads = transform(queue).distinctBy { it.chapter.id }
|
||||||
|
val kept = downloads.mapTo(HashSet()) { it.chapter.id }
|
||||||
|
queue.forEach { download ->
|
||||||
|
if (download.chapter.id !in kept &&
|
||||||
|
(download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE)
|
||||||
|
) {
|
||||||
|
download.status = Download.State.NOT_DOWNLOADED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloads.forEach { it.status = Download.State.QUEUE }
|
||||||
|
store.clear()
|
||||||
|
store.addAll(downloads)
|
||||||
|
downloads
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pause()
|
/**
|
||||||
internalClearQueue()
|
* Removes [chapters] from the queue, keeping the state of the downloads that stay.
|
||||||
addAllToQueue(downloads)
|
*/
|
||||||
|
suspend fun dequeue(chapters: List<Chapter>) = editQueue {
|
||||||
|
removeFromQueue(chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun editQueue(edit: () -> Unit) = lock.withLock {
|
||||||
|
val wasRunning = isRunning
|
||||||
|
if (wasRunning) pauseLocked()
|
||||||
|
|
||||||
|
edit()
|
||||||
|
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
start()
|
if (queueState.value.isEmpty()) stopLocked() else startLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ import eu.kanade.tachiyomi.util.system.cancelNotification
|
|||||||
import eu.kanade.tachiyomi.util.system.getBitmapOrNull
|
import eu.kanade.tachiyomi.util.system.getBitmapOrNull
|
||||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||||
import eu.kanade.tachiyomi.util.system.notify
|
import eu.kanade.tachiyomi.util.system.notify
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import mihon.core.metro.AppCoroutineScope
|
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.i18n.pluralStringResource
|
import tachiyomi.core.common.i18n.pluralStringResource
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchUI
|
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -46,7 +43,6 @@ import mihon.icons.materialsymbols.R as MaterialSymbolsR
|
|||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
class LibraryUpdateNotifier(
|
class LibraryUpdateNotifier(
|
||||||
@AppCoroutineScope private val scope: CoroutineScope,
|
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val securityPreferences: SecurityPreferences,
|
private val securityPreferences: SecurityPreferences,
|
||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
@@ -173,7 +169,7 @@ class LibraryUpdateNotifier(
|
|||||||
*
|
*
|
||||||
* @param updates a list of manga with new updates.
|
* @param updates a list of manga with new updates.
|
||||||
*/
|
*/
|
||||||
fun showUpdateNotifications(updates: List<Pair<Manga, Array<Chapter>>>) {
|
suspend fun showUpdateNotifications(updates: List<Pair<Manga, Array<Chapter>>>) {
|
||||||
// Parent group notification
|
// Parent group notification
|
||||||
context.notify(
|
context.notify(
|
||||||
Notifications.ID_NEW_CHAPTERS,
|
Notifications.ID_NEW_CHAPTERS,
|
||||||
@@ -216,7 +212,6 @@ class LibraryUpdateNotifier(
|
|||||||
|
|
||||||
// Per-manga notification
|
// Per-manga notification
|
||||||
if (!securityPreferences.hideNotificationContent.get()) {
|
if (!securityPreferences.hideNotificationContent.get()) {
|
||||||
scope.launchUI {
|
|
||||||
context.notify(
|
context.notify(
|
||||||
updates.map { (manga, chapters) ->
|
updates.map { (manga, chapters) ->
|
||||||
NotificationManagerCompat.NotificationWithIdAndTag(
|
NotificationManagerCompat.NotificationWithIdAndTag(
|
||||||
@@ -227,7 +222,6 @@ class LibraryUpdateNotifier(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification {
|
private suspend fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification {
|
||||||
val icon = getMangaIcon(manga)
|
val icon = getMangaIcon(manga)
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ import eu.kanade.tachiyomi.util.system.isRunning
|
|||||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
@@ -47,7 +50,6 @@ import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
|||||||
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
@@ -128,7 +130,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
||||||
addMangaToQueue(categoryId)
|
addMangaToQueue(categoryId)
|
||||||
|
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
updateChapterList()
|
updateChapterList()
|
||||||
Result.success()
|
Result.success()
|
||||||
@@ -290,7 +292,10 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
hasDownloads.store(true)
|
hasDownloads.store(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sources update concurrently, and getAndSet is a separate read and write
|
||||||
|
synchronized(libraryPreferences.newUpdatesCount) {
|
||||||
libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size }
|
libraryPreferences.newUpdatesCount.getAndSet { it + newChapters.size }
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to the manga that contains new chapters
|
// Convert to the manga that contains new chapters
|
||||||
newUpdates.add(manga to newChapters.toTypedArray())
|
newUpdates.add(manga to newChapters.toTypedArray())
|
||||||
@@ -485,7 +490,7 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startNow(
|
suspend fun startNow(
|
||||||
workManager: WorkManager,
|
workManager: WorkManager,
|
||||||
category: Category? = null,
|
category: Category? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
@@ -507,12 +512,12 @@ class LibraryUpdateWorker(private val context: Context, workerParams: WorkerPara
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
suspend fun stop(context: Context) {
|
||||||
val workManager = context.workManager
|
val workManager = context.workManager
|
||||||
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
|
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
|
||||||
.addStates(listOf(WorkInfo.State.RUNNING))
|
.addStates(listOf(WorkInfo.State.RUNNING))
|
||||||
.build()
|
.build()
|
||||||
workManager.getWorkInfos(workQuery).get()
|
workManager.getWorkInfosFlow(workQuery).first()
|
||||||
// Should only return one work but just in case
|
// Should only return one work but just in case
|
||||||
.forEach {
|
.forEach {
|
||||||
workManager.cancelWorkById(it.id)
|
workManager.cancelWorkById(it.id)
|
||||||
|
|||||||
@@ -14,17 +14,18 @@ import eu.kanade.tachiyomi.data.notification.Notifications
|
|||||||
import eu.kanade.tachiyomi.util.system.isRunning
|
import eu.kanade.tachiyomi.util.system.isRunning
|
||||||
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
import eu.kanade.tachiyomi.util.system.setForegroundSafely
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.AppGraph
|
import mihon.app.di.AppGraph
|
||||||
import mihon.core.metro.metroGraph
|
import mihon.core.metro.metroGraph
|
||||||
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||||
@@ -58,7 +59,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) :
|
|||||||
|
|
||||||
addMangaToQueue()
|
addMangaToQueue()
|
||||||
|
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
updateMetadata()
|
updateMetadata()
|
||||||
Result.success()
|
Result.success()
|
||||||
@@ -172,7 +173,7 @@ class MetadataUpdateWorker(context: Context, workerParams: WorkerParameters) :
|
|||||||
|
|
||||||
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||||
|
|
||||||
fun startNow(workManager: WorkManager): Boolean {
|
suspend fun startNow(workManager: WorkManager): Boolean {
|
||||||
if (workManager.isRunning(TAG)) {
|
if (workManager.isRunning(TAG)) {
|
||||||
// Already running either as a scheduled or manual job
|
// Already running either as a scheduled or manual job
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import eu.kanade.tachiyomi.util.system.cancelNotification
|
|||||||
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
||||||
import eu.kanade.tachiyomi.util.system.notificationManager
|
import eu.kanade.tachiyomi.util.system.notificationManager
|
||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
@@ -32,7 +30,6 @@ import tachiyomi.domain.download.service.DownloadPreferences
|
|||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
import tachiyomi.i18n.MR
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,9 +63,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
// Resume the download service
|
// Resume the download service
|
||||||
ACTION_RESUME_DOWNLOADS -> downloadManager.startDownloads()
|
ACTION_RESUME_DOWNLOADS -> downloadManager.startDownloads()
|
||||||
// Pause the download service
|
// Pause the download service
|
||||||
ACTION_PAUSE_DOWNLOADS -> downloadManager.pauseDownloads()
|
ACTION_PAUSE_DOWNLOADS -> launchAsync { downloadManager.pauseDownloads() }
|
||||||
// Clear the download queue
|
// Clear the download queue
|
||||||
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue()
|
ACTION_CLEAR_DOWNLOADS -> launchAsync { downloadManager.clearQueue() }
|
||||||
// Launch share activity and dismiss notification
|
// Launch share activity and dismiss notification
|
||||||
ACTION_SHARE_IMAGE ->
|
ACTION_SHARE_IMAGE ->
|
||||||
shareImage(
|
shareImage(
|
||||||
@@ -85,14 +82,6 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
ACTION_CANCEL_RESTORE -> cancelRestore(context)
|
ACTION_CANCEL_RESTORE -> cancelRestore(context)
|
||||||
// Cancel library update and dismiss notification
|
// Cancel library update and dismiss notification
|
||||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
|
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
|
||||||
// Open reader activity
|
|
||||||
ACTION_OPEN_CHAPTER -> {
|
|
||||||
openChapter(
|
|
||||||
context,
|
|
||||||
intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
|
||||||
intent.getLongExtra(EXTRA_CHAPTER_ID, -1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Mark updated manga chapters as read
|
// Mark updated manga chapters as read
|
||||||
ACTION_MARK_AS_READ -> {
|
ACTION_MARK_AS_READ -> {
|
||||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||||
@@ -149,26 +138,6 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
context.startActivity(uri.toShareIntent(context, fileMimeType))
|
context.startActivity(uri.toShareIntent(context, fileMimeType))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts reader activity
|
|
||||||
*
|
|
||||||
* @param context context of application
|
|
||||||
* @param mangaId id of manga
|
|
||||||
* @param chapterId id of chapter
|
|
||||||
*/
|
|
||||||
private fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
|
||||||
val manga = runBlocking { getManga.await(mangaId) }
|
|
||||||
val chapter = runBlocking { getChapter.await(chapterId) }
|
|
||||||
if (manga != null && chapter != null) {
|
|
||||||
val intent = ReaderActivity.newIntent(context, manga.id, chapter.id).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
} else {
|
|
||||||
context.toast(MR.strings.chapter_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method called when user wants to stop a backup restore job.
|
* Method called when user wants to stop a backup restore job.
|
||||||
*
|
*
|
||||||
@@ -184,8 +153,10 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
* @param context context of application
|
* @param context context of application
|
||||||
*/
|
*/
|
||||||
private fun cancelLibraryUpdate(context: Context) {
|
private fun cancelLibraryUpdate(context: Context) {
|
||||||
|
launchAsync {
|
||||||
LibraryUpdateWorker.stop(context)
|
LibraryUpdateWorker.stop(context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method called when user wants to mark manga chapters as read
|
* Method called when user wants to mark manga chapters as read
|
||||||
@@ -253,7 +224,6 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
||||||
|
|
||||||
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
|
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
|
||||||
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
|
|
||||||
private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER"
|
private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER"
|
||||||
|
|
||||||
private const val ACTION_OPEN_ENTRY = "$ID.$NAME.ACTION_OPEN_ENTRY"
|
private const val ACTION_OPEN_ENTRY = "$ID.$NAME.ACTION_OPEN_ENTRY"
|
||||||
@@ -268,7 +238,6 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
|
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
|
||||||
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
|
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
|
||||||
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
|
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
|
||||||
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
|
|
||||||
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
|
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
|||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.track.interactor.UpsertTrack
|
import tachiyomi.domain.track.interactor.UpsertTrack
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
@@ -115,7 +115,7 @@ abstract class BaseTracker(
|
|||||||
try {
|
try {
|
||||||
addTracks.bind(this, item, mangaId)
|
addTracks.bind(this, item, mangaId)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.toast(e.message)
|
context.toast(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ abstract class BaseTracker(
|
|||||||
updateRemote(track)
|
updateRemote(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateRemote(track: Track): Unit = withIOContext {
|
private suspend fun updateRemote(track: Track): Unit = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
update(track)
|
update(track)
|
||||||
track.toDomainTrack(idRequired = false)?.let {
|
track.toDomainTrack(idRequired = false)?.let {
|
||||||
@@ -173,7 +173,7 @@ abstract class BaseTracker(
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
|
logcat(LogPriority.ERROR, e) { "Failed to update remote track data id=$id" }
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.toast(e.message)
|
context.toast(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import eu.kanade.tachiyomi.network.HttpException
|
|||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.add
|
import kotlinx.serialization.json.add
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
@@ -26,7 +28,6 @@ import okhttp3.Headers.Companion.headersOf
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class BangumiApi(
|
class BangumiApi(
|
||||||
@@ -40,7 +41,7 @@ class BangumiApi(
|
|||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
suspend fun addLibManga(track: Track): Track {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("type", track.toApiStatus())
|
put("type", track.toApiStatus())
|
||||||
@@ -52,13 +53,13 @@ class BangumiApi(
|
|||||||
.toRequestBody()
|
.toRequestBody()
|
||||||
// Returns with 202 Accepted on success with no body
|
// Returns with 202 Accepted on success with no body
|
||||||
authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLibManga(track: Track): Track {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("type", track.toApiStatus())
|
put("type", track.toApiStatus())
|
||||||
@@ -76,7 +77,7 @@ class BangumiApi(
|
|||||||
.build()
|
.build()
|
||||||
// Returns with 204 No Content
|
// Returns with 204 No Content
|
||||||
authClient.newCall(request)
|
authClient.newCall(request)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
|
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ class BangumiApi(
|
|||||||
// but that has been the case since 2022 with few significant
|
// but that has been the case since 2022 with few significant
|
||||||
// changes to the schema for this endpoint since
|
// changes to the schema for this endpoint since
|
||||||
// "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动"
|
// "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动"
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_URL/v0/search/subjects?limit=20"
|
val url = "$API_URL/v0/search/subjects?limit=20"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("keyword", search)
|
put("keyword", search)
|
||||||
@@ -112,7 +113,7 @@ class BangumiApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_URL/v0/subjects/$id"
|
val url = "$API_URL/v0/subjects/$id"
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
@@ -126,7 +127,7 @@ class BangumiApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun statusLibManga(track: Track, username: String): Track? {
|
suspend fun statusLibManga(track: Track, username: String): Track? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
|
val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
|
||||||
with(json) {
|
with(json) {
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +153,7 @@ class BangumiApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun accessToken(code: String): BGMOAuth {
|
suspend fun accessToken(code: String): BGMOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
.add("grant_type", "authorization_code")
|
.add("grant_type", "authorization_code")
|
||||||
.add("client_id", CLIENT_ID)
|
.add("client_id", CLIENT_ID)
|
||||||
@@ -169,7 +170,7 @@ class BangumiApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(): BGMUser {
|
suspend fun getCurrentUser(): BGMUser {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET("$API_URL/v0/me"))
|
authClient.newCall(GET("$API_URL/v0/me"))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
|||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
private var oauth: BGMOAuth? = bangumi.restoreToken()
|
private var oauth: BGMOAuth? = bangumi.restoreToken()
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
@@ -23,15 +24,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
|||||||
var currAuth: BGMOAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
|
var currAuth: BGMOAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
|
||||||
|
|
||||||
if (currAuth.isExpired()) {
|
if (currAuth.isExpired()) {
|
||||||
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!))
|
currAuth = refreshToken(chain, currAuth)
|
||||||
if (response.isSuccessful) {
|
|
||||||
currAuth = with(json) {
|
|
||||||
response.parseAs<BGMOAuth>()
|
|
||||||
}
|
|
||||||
newAuth(currAuth)
|
|
||||||
} else {
|
|
||||||
response.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return originalRequest.newBuilder()
|
return originalRequest.newBuilder()
|
||||||
@@ -44,6 +37,18 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
|||||||
.let(chain::proceed)
|
.let(chain::proceed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshToken(chain: Interceptor.Chain, expired: BGMOAuth): BGMOAuth = synchronized(this) {
|
||||||
|
val current = oauth ?: expired
|
||||||
|
if (!current.isExpired()) return@synchronized current
|
||||||
|
|
||||||
|
val response = chain.proceed(BangumiApi.refreshTokenRequest(current.refreshToken!!))
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
response.close()
|
||||||
|
return@synchronized current
|
||||||
|
}
|
||||||
|
with(json) { response.parseAs<BGMOAuth>() }.also(::newAuth)
|
||||||
|
}
|
||||||
|
|
||||||
fun newAuth(oauth: BGMOAuth?) {
|
fun newAuth(oauth: BGMOAuth?) {
|
||||||
this.oauth = oauth
|
this.oauth = oauth
|
||||||
bangumi.saveToken(oauth)
|
bangumi.saveToken(oauth)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import eu.kanade.tachiyomi.network.await
|
|||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.jsonMime
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.add
|
import kotlinx.serialization.json.add
|
||||||
import kotlinx.serialization.json.buildJsonArray
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
@@ -27,7 +29,6 @@ import okhttp3.Headers
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class HikkaApi(
|
|||||||
interceptor: HikkaInterceptor,
|
interceptor: HikkaInterceptor,
|
||||||
) {
|
) {
|
||||||
suspend fun getCurrentUser(): HKUser {
|
suspend fun getCurrentUser(): HKUser {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("${BASE_API_URL}/user/me")
|
.url("${BASE_API_URL}/user/me")
|
||||||
.get()
|
.get()
|
||||||
@@ -51,7 +52,7 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun accessToken(reference: String): HKOAuth {
|
suspend fun accessToken(reference: String): HKOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
client.newCall(authTokenCreate(reference))
|
client.newCall(authTokenCreate(reference))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
@@ -61,7 +62,7 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchManga(query: String): List<TrackSearch> {
|
suspend fun searchManga(query: String): List<TrackSearch> {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
||||||
.appendQueryParameter("page", "1")
|
.appendQueryParameter("page", "1")
|
||||||
.appendQueryParameter("size", "50")
|
.appendQueryParameter("size", "50")
|
||||||
@@ -101,7 +102,7 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaDetails(slug: String): TrackSearch? {
|
suspend fun getMangaDetails(slug: String): TrackSearch? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$BASE_API_URL/manga/$slug"
|
val url = "$BASE_API_URL/manga/$slug"
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
@@ -120,7 +121,7 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRead(track: Track): HKRead? {
|
suspend fun getRead(track: Track): HKRead? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val slug = track.tracking_url.split("/")[4]
|
val slug = track.tracking_url.split("/")[4]
|
||||||
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
|
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
|
||||||
with(json) {
|
with(json) {
|
||||||
@@ -140,7 +141,7 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getManga(track: Track): TrackSearch {
|
suspend fun getManga(track: Track): TrackSearch {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val slug = track.tracking_url.split("/")[4]
|
val slug = track.tracking_url.split("/")[4]
|
||||||
val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon()
|
val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon()
|
||||||
.build()
|
.build()
|
||||||
@@ -155,19 +156,19 @@ class HikkaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteUserManga(track: DomainTrack) {
|
suspend fun deleteUserManga(track: DomainTrack) {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val slug = track.remoteUrl.split("/")[4]
|
val slug = track.remoteUrl.split("/")[4]
|
||||||
|
|
||||||
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
|
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
authClient.newCall(DELETE(url.toString()))
|
authClient.newCall(DELETE(url.toString()))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addUserManga(track: Track): Track {
|
suspend fun addUserManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val slug = track.tracking_url.split("/")[4]
|
val slug = track.tracking_url.split("/")[4]
|
||||||
|
|
||||||
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
|
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
|
||||||
|
|||||||
@@ -10,33 +10,17 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
|
|
||||||
class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
|
class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var oauth: HKOAuth? = hikka.loadOAuth()
|
private var oauth: HKOAuth? = hikka.loadOAuth()
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val originalRequest = chain.request()
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
val currAuth = oauth ?: throw Exception("Hikka: You are not authorized")
|
var currAuth = oauth ?: throw Exception("Hikka: You are not authorized")
|
||||||
|
|
||||||
if (currAuth.isExpired()) {
|
if (currAuth.isExpired()) {
|
||||||
val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken))
|
currAuth = refreshToken(chain, currAuth)
|
||||||
if (!refreshTokenResponse.isSuccessful) {
|
|
||||||
refreshTokenResponse.close()
|
|
||||||
hikka.logout()
|
|
||||||
throw Exception("Hikka: The token is expired")
|
|
||||||
} else {
|
|
||||||
refreshTokenResponse.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken))
|
|
||||||
if (!authTokenInfoResponse.isSuccessful) {
|
|
||||||
authTokenInfoResponse.close()
|
|
||||||
throw Exception("Hikka: Auth token info failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
val authTokenInfo = with(json) {
|
|
||||||
authTokenInfoResponse.parseAs<HKAuthTokenInfo>()
|
|
||||||
}
|
|
||||||
setAuth(HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val authRequest = originalRequest.newBuilder()
|
val authRequest = originalRequest.newBuilder()
|
||||||
@@ -47,6 +31,30 @@ class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
|
|||||||
return chain.proceed(authRequest)
|
return chain.proceed(authRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshToken(chain: Interceptor.Chain, expired: HKOAuth): HKOAuth = synchronized(this) {
|
||||||
|
val currAuth = oauth ?: expired
|
||||||
|
if (!currAuth.isExpired()) return@synchronized currAuth
|
||||||
|
|
||||||
|
val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken))
|
||||||
|
if (!refreshTokenResponse.isSuccessful) {
|
||||||
|
refreshTokenResponse.close()
|
||||||
|
hikka.logout()
|
||||||
|
throw Exception("Hikka: The token is expired")
|
||||||
|
}
|
||||||
|
refreshTokenResponse.close()
|
||||||
|
|
||||||
|
val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken))
|
||||||
|
if (!authTokenInfoResponse.isSuccessful) {
|
||||||
|
authTokenInfoResponse.close()
|
||||||
|
throw Exception("Hikka: Auth token info failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val authTokenInfo = with(json) {
|
||||||
|
authTokenInfoResponse.parseAs<HKAuthTokenInfo>()
|
||||||
|
}
|
||||||
|
HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created).also(::setAuth)
|
||||||
|
}
|
||||||
|
|
||||||
fun setAuth(oauth: HKOAuth?) {
|
fun setAuth(oauth: HKOAuth?) {
|
||||||
this.oauth = oauth
|
this.oauth = oauth
|
||||||
hikka.saveOAuth(oauth)
|
hikka.saveOAuth(oauth)
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import eu.kanade.tachiyomi.network.POST
|
|||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.jsonMime
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okhttp3.Dns
|
import okhttp3.Dns
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -145,7 +146,7 @@ class KavitaApi(
|
|||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTrackSearch(url: String): TrackSearch = withIOContext {
|
suspend fun getTrackSearch(url: String): TrackSearch = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val seriesDto: SeriesDto = with(json) {
|
val seriesDto: SeriesDto = with(json) {
|
||||||
authClient.newCall(GET(url))
|
authClient.newCall(GET(url))
|
||||||
@@ -182,7 +183,7 @@ class KavitaApi(
|
|||||||
authClient.newCall(
|
authClient.newCall(
|
||||||
POST(requestUrl, body = EMPTY_JSON_BODY),
|
POST(requestUrl, body = EMPTY_JSON_BODY),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
return getTrackSearch(track.tracking_url)
|
return getTrackSearch(track.tracking_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
|||||||
import eu.kanade.tachiyomi.network.dataOrElse
|
import eu.kanade.tachiyomi.network.dataOrElse
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import mihon.graphql.kitsu.KitsuAddLibMangaMutation
|
import mihon.graphql.kitsu.KitsuAddLibMangaMutation
|
||||||
import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation
|
import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation
|
||||||
@@ -25,7 +26,6 @@ import mihon.graphql.kitsu.KitsuUpdateLibMangaMutation
|
|||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -181,7 +181,7 @@ class KitsuApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(username: String, password: String): KitsuOAuth {
|
suspend fun login(username: String, password: String): KitsuOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val formBody: RequestBody = FormBody.Builder()
|
val formBody: RequestBody = FormBody.Builder()
|
||||||
.add("username", username)
|
.add("username", username)
|
||||||
.add("password", password)
|
.add("password", password)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
|
|||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
private var oauth: KitsuOAuth? = kitsu.restoreToken()
|
private var oauth: KitsuOAuth? = kitsu.restoreToken()
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
@@ -22,18 +23,19 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
|
|||||||
|
|
||||||
var currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
var currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||||
|
|
||||||
val refreshToken = currAuth.refreshToken!!
|
// Refresh access token if expired, once for all the requests that found it expired.
|
||||||
|
|
||||||
// Refresh access token if expired.
|
|
||||||
if (currAuth.isExpired()) {
|
if (currAuth.isExpired()) {
|
||||||
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
|
currAuth = synchronized(this) {
|
||||||
|
val latest = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||||
|
if (!latest.isExpired()) return@synchronized latest
|
||||||
|
|
||||||
|
val response = chain.proceed(KitsuApi.refreshTokenRequest(latest.refreshToken!!))
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
currAuth = with(json) {
|
with(json) { response.parseAs<KitsuOAuth>() }.also(::newAuth)
|
||||||
response.parseAs<KitsuOAuth>()
|
|
||||||
}
|
|
||||||
newAuth(currAuth)
|
|
||||||
} else {
|
} else {
|
||||||
response.close()
|
response.close()
|
||||||
|
latest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
|||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
@@ -13,7 +15,6 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class KomgaApi(
|
|||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
suspend fun getTrackSearch(url: String): TrackSearch =
|
suspend fun getTrackSearch(url: String): TrackSearch =
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val track = with(json) {
|
val track = with(json) {
|
||||||
if (url.contains(READLIST_API)) {
|
if (url.contains(READLIST_API)) {
|
||||||
@@ -93,7 +94,7 @@ class KomgaApi(
|
|||||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
return getTrackSearch(track.tracking_url)
|
return getTrackSearch(track.tracking_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
|||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import eu.kanade.tachiyomi.util.PkceUtil
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
@@ -31,7 +33,6 @@ import okhttp3.FormBody
|
|||||||
import okhttp3.Headers.Companion.headersOf
|
import okhttp3.Headers.Companion.headersOf
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -64,7 +65,7 @@ class MangaBakaApi(
|
|||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
suspend fun addLibManga(track: Track): Track {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("is_private", track.private)
|
put("is_private", track.private)
|
||||||
@@ -87,7 +88,7 @@ class MangaBakaApi(
|
|||||||
|
|
||||||
authClient
|
authClient
|
||||||
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
|
|
||||||
// only returns 201 with the body { "status": 201, "data": true }, so no library ID for us
|
// only returns 201 with the body { "status": 201, "data": true }, so no library ID for us
|
||||||
track
|
track
|
||||||
@@ -95,17 +96,17 @@ class MangaBakaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteLibManga(track: DomainTrack) {
|
suspend fun deleteLibManga(track: DomainTrack) {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
val url = "$LIBRARY_API_URL/${track.remoteId}"
|
val url = "$LIBRARY_API_URL/${track.remoteId}"
|
||||||
|
|
||||||
authClient
|
authClient
|
||||||
.newCall(DELETE(url))
|
.newCall(DELETE(url))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findLibManga(track: Track): Track? {
|
suspend fun findLibManga(track: Track): Track? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
try {
|
try {
|
||||||
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
||||||
@@ -141,7 +142,7 @@ class MangaBakaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLibManga(track: Track): Track {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
val url = "$LIBRARY_API_URL/${track.remote_id}"
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("state", track.toApiStatus())
|
put("state", track.toApiStatus())
|
||||||
@@ -172,14 +173,14 @@ class MangaBakaApi(
|
|||||||
|
|
||||||
authClient
|
authClient
|
||||||
.newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
.newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
|
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun search(search: String): List<TrackSearch> {
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon()
|
val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon()
|
||||||
.appendQueryParameter("q", search)
|
.appendQueryParameter("q", search)
|
||||||
.appendQueryParameter("type_not", "novel")
|
.appendQueryParameter("type_not", "novel")
|
||||||
@@ -213,7 +214,7 @@ class MangaBakaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$API_BASE_URL/v1/series".toUri().buildUpon()
|
val url = "$API_BASE_URL/v1/series".toUri().buildUpon()
|
||||||
.appendPath(id.toString())
|
.appendPath(id.toString())
|
||||||
.build()
|
.build()
|
||||||
@@ -235,7 +236,7 @@ class MangaBakaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(): MangaBakaUserProfile {
|
suspend fun getCurrentUser(): MangaBakaUserProfile {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET("$API_BASE_URL/v1/my/profile"))
|
authClient.newCall(GET("$API_BASE_URL/v1/my/profile"))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
@@ -246,7 +247,7 @@ class MangaBakaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getAccessToken(code: String): MangaBakaOAuth {
|
suspend fun getAccessToken(code: String): MangaBakaOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val formBody = FormBody.Builder()
|
val formBody = FormBody.Builder()
|
||||||
.add("client_id", CLIENT_ID)
|
.add("client_id", CLIENT_ID)
|
||||||
.add("code", code)
|
.add("code", code)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor {
|
|||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken()
|
private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken()
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
@@ -18,15 +19,19 @@ class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor {
|
|||||||
|
|
||||||
var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka")
|
var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka")
|
||||||
|
|
||||||
|
// Refresh once for all the requests that found the token expired
|
||||||
if (currentAuth.isExpired()) {
|
if (currentAuth.isExpired()) {
|
||||||
val response = chain.proceed(MangaBakaApi.refreshTokenRequest(currentAuth.refreshToken))
|
currentAuth = synchronized(this) {
|
||||||
|
val latest = oauth ?: throw Exception("Not authenticated with MangaBaka")
|
||||||
|
if (!latest.isExpired()) return@synchronized latest
|
||||||
|
|
||||||
|
val response = chain.proceed(MangaBakaApi.refreshTokenRequest(latest.refreshToken))
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
currentAuth = with(json) {
|
with(json) { response.parseAs<MangaBakaOAuth>() }.also(::setAuth)
|
||||||
response.parseAs<MangaBakaOAuth>()
|
|
||||||
}
|
|
||||||
setAuth(currentAuth)
|
|
||||||
} else {
|
} else {
|
||||||
response.close()
|
response.close()
|
||||||
|
latest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import eu.kanade.tachiyomi.network.PUT
|
|||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.add
|
import kotlinx.serialization.json.add
|
||||||
import kotlinx.serialization.json.addJsonObject
|
import kotlinx.serialization.json.addJsonObject
|
||||||
@@ -27,7 +29,6 @@ import kotlinx.serialization.json.putJsonObject
|
|||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||||
|
|
||||||
@@ -44,11 +45,13 @@ class MangaUpdatesApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
|
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
|
||||||
val listItem = with(json) {
|
val listItem = withContext(Dispatchers.IO) {
|
||||||
|
with(json) {
|
||||||
authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
|
authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<MUListItem>()
|
.parseAs<MUListItem>()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val rating = getSeriesRating(track)
|
val rating = getSeriesRating(track)
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ class MangaUpdatesApi(
|
|||||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
|
|
||||||
updateSeriesRating(track)
|
updateSeriesRating(track)
|
||||||
}
|
}
|
||||||
@@ -113,16 +116,18 @@ class MangaUpdatesApi(
|
|||||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSeriesRating(track: Track): MURating? {
|
private suspend fun getSeriesRating(track: Track): MURating? {
|
||||||
return try {
|
return try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
|
authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<MURating>()
|
.parseAs<MURating>()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -140,12 +145,12 @@ class MangaUpdatesApi(
|
|||||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
} else {
|
} else {
|
||||||
authClient.newCall(
|
authClient.newCall(
|
||||||
DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
|
DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +166,8 @@ class MangaUpdatesApi(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return with(json) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
with(json) {
|
||||||
client.newCall(
|
client.newCall(
|
||||||
POST(
|
POST(
|
||||||
url = "$BASE_URL/v1/series/search",
|
url = "$BASE_URL/v1/series/search",
|
||||||
@@ -174,9 +180,10 @@ class MangaUpdatesApi(
|
|||||||
.map { it.record }
|
.map { it.record }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getSeriesDetails(id: Long): MURecord? {
|
suspend fun getSeriesDetails(id: Long): MURecord? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$BASE_URL/v1/series/$id"
|
val url = "$BASE_URL/v1/series/$id"
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
@@ -197,7 +204,8 @@ class MangaUpdatesApi(
|
|||||||
put("username", username)
|
put("username", username)
|
||||||
put("password", password)
|
put("password", password)
|
||||||
}
|
}
|
||||||
return with(json) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
with(json) {
|
||||||
client.newCall(
|
client.newCall(
|
||||||
PUT(
|
PUT(
|
||||||
url = "$BASE_URL/v1/account/login",
|
url = "$BASE_URL/v1/account/login",
|
||||||
@@ -209,14 +217,17 @@ class MangaUpdatesApi(
|
|||||||
.context
|
.context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(): MUCurrentUser {
|
suspend fun getCurrentUser(): MUCurrentUser {
|
||||||
return with(json) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
with(json) {
|
||||||
authClient.newCall(GET("$BASE_URL/v1/account/profile"))
|
authClient.newCall(GET("$BASE_URL/v1/account/profile"))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<MUCurrentUser>()
|
.parseAs<MUCurrentUser>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BASE_URL = "https://api.mangaupdates.com"
|
private const val BASE_URL = "https://api.mangaupdates.com"
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import eu.kanade.tachiyomi.network.await
|
|||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import eu.kanade.tachiyomi.util.PkceUtil
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -41,7 +42,7 @@ class MyAnimeListApi(
|
|||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
suspend fun getAccessToken(authCode: String): MALOAuth {
|
suspend fun getAccessToken(authCode: String): MALOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val formBody: RequestBody = FormBody.Builder()
|
val formBody: RequestBody = FormBody.Builder()
|
||||||
.add("client_id", CLIENT_ID)
|
.add("client_id", CLIENT_ID)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -57,7 +58,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(): String {
|
suspend fun getCurrentUser(): String {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$BASE_API_URL/users/@me")
|
.url("$BASE_API_URL/users/@me")
|
||||||
.get()
|
.get()
|
||||||
@@ -72,7 +73,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun search(query: String): List<TrackSearch> {
|
suspend fun search(query: String): List<TrackSearch> {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
||||||
// MAL API throws a 400 when the query is over 64 characters...
|
// MAL API throws a 400 when the query is over 64 characters...
|
||||||
.appendQueryParameter("q", query.take(64))
|
.appendQueryParameter("q", query.take(64))
|
||||||
@@ -91,7 +92,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaDetails(id: Int): TrackSearch {
|
suspend fun getMangaDetails(id: Int): TrackSearch {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
val url = "$BASE_API_URL/manga".toUri().buildUpon()
|
||||||
.appendPath(id.toString())
|
.appendPath(id.toString())
|
||||||
.appendQueryParameter("fields", SEARCH_FIELDS)
|
.appendQueryParameter("fields", SEARCH_FIELDS)
|
||||||
@@ -106,7 +107,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateItem(track: Track): Track {
|
suspend fun updateItem(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val formBodyBuilder = FormBody.Builder()
|
val formBodyBuilder = FormBody.Builder()
|
||||||
.add("status", track.toMyAnimeListStatus() ?: "reading")
|
.add("status", track.toMyAnimeListStatus() ?: "reading")
|
||||||
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
|
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
|
||||||
@@ -148,15 +149,15 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteItem(track: DomainTrack) {
|
suspend fun deleteItem(track: DomainTrack) {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
authClient
|
authClient
|
||||||
.newCall(DELETE(mangaUrl(track.remoteId).toString()))
|
.newCall(DELETE(mangaUrl(track.remoteId).toString()))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findListItem(track: Track): Track? {
|
suspend fun findListItem(track: Track): Track? {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val uri = "$BASE_API_URL/manga".toUri().buildUpon()
|
val uri = "$BASE_API_URL/manga".toUri().buildUpon()
|
||||||
.appendPath(track.remote_id.toString())
|
.appendPath(track.remote_id.toString())
|
||||||
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
|
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
|
||||||
@@ -174,7 +175,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
|
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val myListSearchResult = getListPage(offset)
|
val myListSearchResult = getListPage(offset)
|
||||||
|
|
||||||
val matches = myListSearchResult.data
|
val matches = myListSearchResult.data
|
||||||
@@ -191,7 +192,7 @@ class MyAnimeListApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getListPage(offset: Int): MALSearchResult {
|
private suspend fun getListPage(offset: Int): MALSearchResult {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
|
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
|
||||||
.appendQueryParameter("fields", SEARCH_FIELDS)
|
.appendQueryParameter("fields", SEARCH_FIELDS)
|
||||||
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString())
|
.appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.network.dataOrElse
|
|||||||
import eu.kanade.tachiyomi.network.jsonMime
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
@@ -28,7 +29,6 @@ import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
|
|||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class ShikimoriApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
putJsonObject("user_rate") {
|
putJsonObject("user_rate") {
|
||||||
@@ -82,7 +82,7 @@ class ShikimoriApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLibManga(track: Track): Track {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
putJsonObject("user_rate") {
|
putJsonObject("user_rate") {
|
||||||
put("chapters", track.last_chapter_read.toInt())
|
put("chapters", track.last_chapter_read.toInt())
|
||||||
@@ -109,10 +109,10 @@ class ShikimoriApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteLibManga(track: DomainTrack) {
|
suspend fun deleteLibManga(track: DomainTrack) {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
authClient
|
authClient
|
||||||
.newCall(DELETE("$API_URL/v2/user_rates/${track.libraryId}"))
|
.newCall(DELETE("$API_URL/v2/user_rates/${track.libraryId}"))
|
||||||
.awaitSuccess()
|
.awaitSuccess().close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ class ShikimoriApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun accessToken(code: String): SMOAuth {
|
suspend fun accessToken(code: String): SMOAuth {
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
with(json) {
|
with(json) {
|
||||||
client.newCall(accessTokenRequest(code))
|
client.newCall(accessTokenRequest(code))
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
|
|||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
private var oauth: SMOAuth? = shikimori.restoreToken()
|
private var oauth: SMOAuth? = shikimori.restoreToken()
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
@@ -22,21 +23,24 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
|
|||||||
|
|
||||||
var currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
|
var currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
|
||||||
|
|
||||||
// Refresh access token if expired.
|
// Refresh access token if expired, once for all the requests that found it expired.
|
||||||
if (currAuth.isExpired()) {
|
if (currAuth.isExpired()) {
|
||||||
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(currAuth.refreshToken!!))
|
currAuth = synchronized(this) {
|
||||||
|
val latest = oauth ?: throw Exception("Not authenticated with Shikimori")
|
||||||
|
if (!latest.isExpired()) return@synchronized latest
|
||||||
|
|
||||||
|
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(latest.refreshToken!!))
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
currAuth = with(json) {
|
with(json) { response.parseAs<SMOAuth>() }.also(::newAuth)
|
||||||
response.parseAs<SMOAuth>()
|
|
||||||
}
|
|
||||||
newAuth(currAuth)
|
|
||||||
} else {
|
} else {
|
||||||
response.close()
|
response.close()
|
||||||
|
latest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add the authorization header to the original request.
|
// Add the authorization header to the original request.
|
||||||
val authRequest = originalRequest.newBuilder()
|
val authRequest = originalRequest.newBuilder()
|
||||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
.addHeader("Authorization", "Bearer ${currAuth.accessToken}")
|
||||||
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
|
|||||||
|
|
||||||
private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong()
|
private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong()
|
||||||
|
|
||||||
private fun getPrefTrackerDelete(): Boolean {
|
private suspend fun getPrefTrackerDelete(): Boolean {
|
||||||
val preferences = api.sourcePreferences()
|
val preferences = api.sourcePreferences()
|
||||||
return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT)
|
return preferences.getBoolean(TRACKER_DELETE_KEY, TRACKER_DELETE_DEFAULT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,14 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
|||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.dataOrElse
|
import eu.kanade.tachiyomi.network.dataOrElse
|
||||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
import eu.kanade.tachiyomi.source.Source
|
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import mihon.graphql.suwayomi.SuwayomiGetMangaQuery
|
import mihon.graphql.suwayomi.SuwayomiGetMangaQuery
|
||||||
import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery
|
import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery
|
||||||
import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation
|
import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation
|
||||||
import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation
|
import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation
|
||||||
import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation
|
import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -25,28 +22,30 @@ class SuwayomiApi(
|
|||||||
private val trackerId: Long,
|
private val trackerId: Long,
|
||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
) {
|
) {
|
||||||
// Blocking is fine here: these are only touched from OkHttp and tracker threads.
|
@Volatile
|
||||||
private val source: Source by lazy { runBlocking { sourceManager.get(sourceId)!! } }
|
private var connection: Connection? = null
|
||||||
private val httpSource: HttpSource by lazy { source as HttpSource }
|
|
||||||
private val configurableSource: ConfigurableSource by lazy { source as ConfigurableSource }
|
|
||||||
private val client: OkHttpClient by lazy { httpSource.client }
|
|
||||||
private val baseUrl: String by lazy { httpSource.baseUrl.trimEnd('/') }
|
|
||||||
private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
|
|
||||||
|
|
||||||
private val graphQlClient by lazy {
|
private suspend fun connection(): Connection {
|
||||||
ApolloClient.Builder()
|
connection?.let { return it }
|
||||||
.serverUrl(apiUrl)
|
val source = sourceManager.get(sourceId) as HttpSource
|
||||||
.okHttpClient(client)
|
val baseUrl = source.baseUrl.trimEnd('/')
|
||||||
|
val graphQlClient = ApolloClient.Builder()
|
||||||
|
.serverUrl("$baseUrl/api/graphql")
|
||||||
|
.okHttpClient(source.client)
|
||||||
.dispatcher(Dispatchers.IO)
|
.dispatcher(Dispatchers.IO)
|
||||||
// required to log the error body in dataOrElse, which also properly closes it
|
// required to log the error body in dataOrElse, which also properly closes it
|
||||||
.httpExposeErrorBody(true)
|
.httpExposeErrorBody(true)
|
||||||
.build()
|
.build()
|
||||||
|
return Connection(source, baseUrl, graphQlClient).also { connection = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
|
suspend fun sourcePreferences(): SharedPreferences {
|
||||||
|
return (connection().source as ConfigurableSource).sourcePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getTrackSearch(mangaId: Long): TrackSearch? {
|
suspend fun getTrackSearch(mangaId: Long): TrackSearch? {
|
||||||
return graphQlClient
|
val connection = connection()
|
||||||
|
return connection.graphQlClient
|
||||||
.query(
|
.query(
|
||||||
SuwayomiGetMangaQuery(mangaId = mangaId.toInt()),
|
SuwayomiGetMangaQuery(mangaId = mangaId.toInt()),
|
||||||
)
|
)
|
||||||
@@ -55,11 +54,12 @@ class SuwayomiApi(
|
|||||||
errorLog = "Suwayomi: Failed to find manga in library",
|
errorLog = "Suwayomi: Failed to find manga in library",
|
||||||
default = { null },
|
default = { null },
|
||||||
) {
|
) {
|
||||||
it.manga.mangaFragment.toTrackSearch(trackerId, baseUrl)
|
it.manga.mangaFragment.toTrackSearch(trackerId, connection.baseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? {
|
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? {
|
||||||
|
val graphQlClient = connection().graphQlClient
|
||||||
val mangaId = track.remote_id
|
val mangaId = track.remote_id
|
||||||
|
|
||||||
val chaptersToMark = graphQlClient
|
val chaptersToMark = graphQlClient
|
||||||
@@ -114,4 +114,10 @@ class SuwayomiApi(
|
|||||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class Connection(
|
||||||
|
val source: HttpSource,
|
||||||
|
val baseUrl: String,
|
||||||
|
val graphQlClient: ApolloClient,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import dev.zacsweers.metro.Inject
|
|||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.util.system.isFossBuildType
|
import eu.kanade.tachiyomi.util.system.isFossBuildType
|
||||||
import eu.kanade.tachiyomi.util.system.isNightlyBuildType
|
import eu.kanade.tachiyomi.util.system.isNightlyBuildType
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -18,7 +19,7 @@ class AppUpdateChecker(
|
|||||||
// return GetApplicationRelease.Result.OsTooOld
|
// return GetApplicationRelease.Result.OsTooOld
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val result = getApplicationRelease.await(
|
val result = getApplicationRelease.await(
|
||||||
GetApplicationRelease.Arguments(
|
GetApplicationRelease.Arguments(
|
||||||
isFossBuildType,
|
isFossBuildType,
|
||||||
|
|||||||
@@ -31,17 +31,19 @@ import kotlinx.coroutines.flow.onStart
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import mihon.domain.extension.interactor.UpdateExtensionStores
|
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||||
import mihon.domain.extension.model.ExtensionStore
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.source.model.StubSource
|
import tachiyomi.domain.source.model.StubSource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manager of extensions installed as another apk which extend the available sources. It handles
|
* The manager of extensions installed as another apk which extend the available sources. It handles
|
||||||
@@ -65,7 +67,10 @@ class ExtensionManager(
|
|||||||
|
|
||||||
private val initialized = CompletableDeferred<Unit>()
|
private val initialized = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
private val iconMap = mutableMapOf<String, Drawable>()
|
// A full load replaces both maps, so install events wait for it and apply on top instead of being overwritten
|
||||||
|
private val loadMutex = Mutex()
|
||||||
|
|
||||||
|
private val iconMap = ConcurrentHashMap<String, Drawable>()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var stores = emptyList<ExtensionStore>()
|
private var stores = emptyList<ExtensionStore>()
|
||||||
@@ -167,7 +172,7 @@ class ExtensionManager(
|
|||||||
* again, so one can move between loaded and not loaded in either direction, while extensions
|
* again, so one can move between loaded and not loaded in either direction, while extensions
|
||||||
* that still pass keep the instances they already had.
|
* that still pass keep the instances they already had.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadExtensions() {
|
private suspend fun loadExtensions() = loadMutex.withLock {
|
||||||
try {
|
try {
|
||||||
val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value)
|
val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value)
|
||||||
|
|
||||||
@@ -197,7 +202,7 @@ class ExtensionManager(
|
|||||||
fetchExtensions()
|
fetchExtensions()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
withUIContext { context.toast(MR.strings.extension_api_error) }
|
withContext(Dispatchers.Main) { context.toast(MR.strings.extension_api_error) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +234,7 @@ class ExtensionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchExtensions(): List<Extension.Available> {
|
private suspend fun fetchExtensions(): List<Extension.Available> {
|
||||||
return withIOContext { extensionStoreRepository.fetchExtensions() }
|
return extensionStoreRepository.fetchExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setAvailableExtensions(extensions: List<Extension.Available>) {
|
private fun setAvailableExtensions(extensions: List<Extension.Available>) {
|
||||||
@@ -340,7 +345,7 @@ class ExtensionManager(
|
|||||||
* @param extension The extension to be registered.
|
* @param extension The extension to be registered.
|
||||||
*/
|
*/
|
||||||
private fun registerExtension(extension: Extension.Loaded) {
|
private fun registerExtension(extension: Extension.Loaded) {
|
||||||
loadedExtensionMapFlow.value += extension
|
loadedExtensionMapFlow.update { it + extension }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,8 +355,8 @@ class ExtensionManager(
|
|||||||
* @param pkgName The package name of the uninstalled application.
|
* @param pkgName The package name of the uninstalled application.
|
||||||
*/
|
*/
|
||||||
private fun unregisterExtension(pkgName: String) {
|
private fun unregisterExtension(pkgName: String) {
|
||||||
loadedExtensionMapFlow.value -= pkgName
|
loadedExtensionMapFlow.update { it - pkgName }
|
||||||
notLoadedExtensionMapFlow.value -= pkgName
|
notLoadedExtensionMapFlow.update { it - pkgName }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,19 +364,19 @@ class ExtensionManager(
|
|||||||
*/
|
*/
|
||||||
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
|
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
|
||||||
|
|
||||||
override fun onExtensionLoaded(extension: Extension.Loaded) {
|
override suspend fun onExtensionLoaded(extension: Extension.Loaded) = loadMutex.withLock {
|
||||||
registerExtension(extension)
|
registerExtension(extension)
|
||||||
notLoadedExtensionMapFlow.value -= extension.pkgName
|
notLoadedExtensionMapFlow.update { it - extension.pkgName }
|
||||||
refreshStatuses()
|
refreshStatuses()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExtensionNotLoaded(extension: Extension.NotLoaded) {
|
override suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded) = loadMutex.withLock {
|
||||||
loadedExtensionMapFlow.value -= extension.pkgName
|
loadedExtensionMapFlow.update { it - extension.pkgName }
|
||||||
notLoadedExtensionMapFlow.value += extension
|
notLoadedExtensionMapFlow.update { it + extension }
|
||||||
refreshStatuses()
|
refreshStatuses()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPackageUninstalled(pkgName: String) {
|
override suspend fun onPackageUninstalled(pkgName: String) = loadMutex.withLock {
|
||||||
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||||
unregisterExtension(pkgName)
|
unregisterExtension(pkgName)
|
||||||
updatePendingUpdatesCount()
|
updatePendingUpdatesCount()
|
||||||
@@ -385,7 +390,8 @@ class ExtensionManager(
|
|||||||
private fun refreshStatuses() {
|
private fun refreshStatuses() {
|
||||||
val available = availableExtensionListFlow.value
|
val available = availableExtensionListFlow.value
|
||||||
if (available.isNotEmpty()) {
|
if (available.isNotEmpty()) {
|
||||||
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
|
loadedExtensionMapFlow.update { current ->
|
||||||
|
current.mapValues { (_, extension) ->
|
||||||
val listing = extension.findListing(available)
|
val listing = extension.findListing(available)
|
||||||
extension.copy(
|
extension.copy(
|
||||||
hasUpdate = extension.findUpdate(available) != null,
|
hasUpdate = extension.findUpdate(available) != null,
|
||||||
@@ -393,13 +399,16 @@ class ExtensionManager(
|
|||||||
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
|
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) ->
|
}
|
||||||
|
notLoadedExtensionMapFlow.update { current ->
|
||||||
|
current.mapValues { (_, extension) ->
|
||||||
extension.copy(
|
extension.copy(
|
||||||
hasUpdate = extension.findUpdate(available) != null,
|
hasUpdate = extension.findUpdate(available) != null,
|
||||||
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
|
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updatePendingUpdatesCount()
|
updatePendingUpdatesCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.lang.use
|
||||||
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
|
||||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
|
|
||||||
@@ -22,6 +27,8 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
|
|
||||||
private val packageInstaller = service.packageManager.packageInstaller
|
private val packageInstaller = service.packageManager.packageInstaller
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private val packageActionReceiver = object : BroadcastReceiver() {
|
private val packageActionReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
@@ -68,6 +75,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var activeSession: Pair<Entry, Int>? = null
|
private var activeSession: Pair<Entry, Int>? = null
|
||||||
|
|
||||||
// Always ready
|
// Always ready
|
||||||
@@ -76,6 +84,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
override fun processEntry(entry: Entry) {
|
override fun processEntry(entry: Entry) {
|
||||||
super.processEntry(entry)
|
super.processEntry(entry)
|
||||||
activeSession = null
|
activeSession = null
|
||||||
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
@@ -107,9 +116,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
|
||||||
activeSession?.let { (_, sessionId) ->
|
activeSession?.let { (_, sessionId) ->
|
||||||
packageInstaller.abandonSession(sessionId)
|
// Already abandoned when the entry was cancelled mid-copy
|
||||||
|
runCatching { packageInstaller.abandonSession(sessionId) }
|
||||||
|
}
|
||||||
|
if (getActiveEntry() == entry) continueQueue(InstallStep.Error)
|
||||||
}
|
}
|
||||||
continueQueue(InstallStep.Error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +141,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
service.unregisterReceiver(packageActionReceiver)
|
service.unregisterReceiver(packageActionReceiver)
|
||||||
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.shizuku.IShellInterface
|
import mihon.app.shizuku.IShellInterface
|
||||||
import mihon.app.shizuku.ShellInterface
|
import mihon.app.shizuku.ShellInterface
|
||||||
@@ -117,6 +118,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
|
|||||||
|
|
||||||
override fun processEntry(entry: Entry) {
|
override fun processEntry(entry: Entry) {
|
||||||
super.processEntry(entry)
|
super.processEntry(entry)
|
||||||
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
|
service.contentResolver.openAssetFileDescriptor(entry.uri, "r").use {
|
||||||
shellInterface?.install(it, statusIntent.intentSender)
|
shellInterface?.install(it, statusIntent.intentSender)
|
||||||
@@ -127,6 +129,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
|
|||||||
continueQueue(InstallStep.Error)
|
continueQueue(InstallStep.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't cancel if entry is already started installing
|
// Don't cancel if entry is already started installing
|
||||||
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
|
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
@@ -25,6 +27,22 @@ internal class ExtensionInstallReceiver(
|
|||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) : BroadcastReceiver() {
|
) : BroadcastReceiver() {
|
||||||
|
|
||||||
|
// Handled one at a time in arrival order, so a quick removal can't overtake the load of an earlier install
|
||||||
|
private val events = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
for (event in events) {
|
||||||
|
try {
|
||||||
|
handle(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
logcat(LogPriority.ERROR, e) { "Failed to handle $event" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun register(context: Context) {
|
fun register(context: Context) {
|
||||||
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
}
|
}
|
||||||
@@ -49,31 +67,14 @@ internal class ExtensionInstallReceiver(
|
|||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||||
if (isReplacing(intent)) return
|
if (isReplacing(intent)) return
|
||||||
|
getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) }
|
||||||
scope.launch {
|
|
||||||
when (val extension = getExtensionFromIntent(context, intent)) {
|
|
||||||
is Extension.Loaded -> listener.onExtensionLoaded(extension)
|
|
||||||
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
|
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||||
scope.launch {
|
getPackageNameFromIntent(intent)?.let { events.trySend(Event.Installed(context, it)) }
|
||||||
when (val extension = getExtensionFromIntent(context, intent)) {
|
|
||||||
is Extension.Loaded -> listener.onExtensionLoaded(extension)
|
|
||||||
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
|
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
|
Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
|
||||||
if (isReplacing(intent)) return
|
if (isReplacing(intent)) return
|
||||||
|
getPackageNameFromIntent(intent)?.let { events.trySend(Event.Removed(it)) }
|
||||||
val pkgName = getPackageNameFromIntent(intent)
|
|
||||||
if (pkgName != null) {
|
|
||||||
listener.onPackageUninstalled(pkgName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,19 +88,20 @@ internal class ExtensionInstallReceiver(
|
|||||||
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private suspend fun handle(event: Event) {
|
||||||
* Returns the extension triggered by the given intent.
|
when (event) {
|
||||||
*
|
is Event.Installed -> when (
|
||||||
* @param context The application context.
|
val extension = ExtensionLoader.loadExtensionFromPkgName(
|
||||||
* @param intent The intent containing the package name of the extension.
|
event.context,
|
||||||
*/
|
event.pkgName,
|
||||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): Extension.Installed? {
|
)
|
||||||
val pkgName = getPackageNameFromIntent(intent)
|
) {
|
||||||
if (pkgName == null) {
|
is Extension.Loaded -> listener.onExtensionLoaded(extension)
|
||||||
logcat(LogPriority.WARN) { "Package name not found" }
|
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
|
||||||
return null
|
null -> {}
|
||||||
|
}
|
||||||
|
is Event.Removed -> listener.onPackageUninstalled(event.pkgName)
|
||||||
}
|
}
|
||||||
return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,9 +115,14 @@ internal class ExtensionInstallReceiver(
|
|||||||
* Listener that receives extension installation events.
|
* Listener that receives extension installation events.
|
||||||
*/
|
*/
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onExtensionLoaded(extension: Extension.Loaded)
|
suspend fun onExtensionLoaded(extension: Extension.Loaded)
|
||||||
fun onExtensionNotLoaded(extension: Extension.NotLoaded)
|
suspend fun onExtensionNotLoaded(extension: Extension.NotLoaded)
|
||||||
fun onPackageUninstalled(pkgName: String)
|
suspend fun onPackageUninstalled(pkgName: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface Event {
|
||||||
|
data class Installed(val context: Context, val pkgName: String) : Event
|
||||||
|
data class Removed(val pkgName: String) : Event
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -11,21 +11,26 @@ import eu.kanade.tachiyomi.extension.installer.Installer
|
|||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installer which installs, updates and uninstalls the extensions.
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
@@ -40,11 +45,11 @@ class ExtensionInstaller(
|
|||||||
networkHelper: NetworkHelper,
|
networkHelper: NetworkHelper,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val activeJobs = mutableMapOf<String, Job>()
|
private val activeJobs = ConcurrentHashMap<String, Job>()
|
||||||
private val activeSteps = mutableMapOf<Long, MutableStateFlow<InstallStep>>()
|
private val activeSteps = ConcurrentHashMap<Long, MutableStateFlow<InstallStep>>()
|
||||||
private val extensionInstaller = basePreferences.extensionInstaller
|
private val extensionInstaller = basePreferences.extensionInstaller
|
||||||
|
|
||||||
private val httpClient: OkHttpClient = networkHelper.client
|
private val httpClient: OkHttpClient by lazy { networkHelper.client }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||||
@@ -68,16 +73,19 @@ class ExtensionInstaller(
|
|||||||
try {
|
try {
|
||||||
step.value = InstallStep.Downloading
|
step.value = InstallStep.Downloading
|
||||||
val request = Request.Builder().url(extension.apkUrl).build()
|
val request = Request.Builder().url(extension.apkUrl).build()
|
||||||
val response = httpClient.newCall(request).execute()
|
httpClient.newCall(request).await().use { response ->
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw Exception("Failed to download extension")
|
throw Exception("Failed to download extension")
|
||||||
}
|
}
|
||||||
|
// Interruptible so cancelInstall stops the body read too
|
||||||
|
runInterruptible {
|
||||||
response.body.byteStream().use { input ->
|
response.body.byteStream().use { input ->
|
||||||
tmpFile.outputStream().use { output ->
|
tmpFile.outputStream().use { output ->
|
||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The store's index names the apk, but nothing ties what's served there to the store's key
|
// The store's index names the apk, but nothing ties what's served there to the store's key
|
||||||
val packageInfo = ExtensionLoader.getArchivePackageInfo(context, tmpFile)
|
val packageInfo = ExtensionLoader.getArchivePackageInfo(context, tmpFile)
|
||||||
@@ -87,17 +95,15 @@ class ExtensionInstaller(
|
|||||||
throw Exception("Extension isn't signed with the key of ${extension.store.name}")
|
throw Exception("Extension isn't signed with the key of ${extension.store.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureActive()
|
||||||
step.value = InstallStep.Installing
|
step.value = InstallStep.Installing
|
||||||
installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled)
|
installApk(downloadId, tmpFile, packageInfo, isUpdateForPrivatelyInstalled)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is InterruptedException) {
|
if (e is CancellationException) throw e
|
||||||
// Canceled
|
|
||||||
} else {
|
|
||||||
logcat(LogPriority.INFO, e)
|
logcat(LogPriority.INFO, e)
|
||||||
step.value = InstallStep.Error
|
step.value = InstallStep.Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
activeJobs[extension.pkgName] = job
|
activeJobs[extension.pkgName] = job
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import eu.kanade.tachiyomi.source.Source
|
|||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.util.lang.Hash
|
import eu.kanade.tachiyomi.util.lang.Hash
|
||||||
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.data.dalvik.DelegateLastClassLoaderCompat
|
import mihon.data.dalvik.DelegateLastClassLoaderCompat
|
||||||
import mihon.domain.extension.model.ContentWarning
|
import mihon.domain.extension.model.ContentWarning
|
||||||
import mihon.domain.extension.model.ExtensionStore
|
import mihon.domain.extension.model.ExtensionStore
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -147,15 +148,16 @@ internal object ExtensionLoader {
|
|||||||
}
|
}
|
||||||
?.filter { isPackageAnExtension(it) }
|
?.filter { isPackageAnExtension(it) }
|
||||||
?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
||||||
?: emptySequence()
|
?.toList()
|
||||||
|
.orEmpty()
|
||||||
|
val privateExtPkgsByName = privateExtPkgs.groupBy { it.packageInfo.packageName }
|
||||||
|
|
||||||
val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
||||||
// Remove duplicates. Shared takes priority than private by default
|
// Remove duplicates. Shared takes priority than private by default
|
||||||
.distinctBy { it.packageInfo.packageName }
|
.distinctBy { it.packageInfo.packageName }
|
||||||
// Compare version number
|
// Compare version number
|
||||||
.mapNotNull { sharedPkg ->
|
.mapNotNull { sharedPkg ->
|
||||||
val privatePkg = privateExtPkgs
|
val privatePkg = privateExtPkgsByName[sharedPkg.packageInfo.packageName]?.singleOrNull()
|
||||||
.singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
|
||||||
selectExtensionPackage(sharedPkg, privatePkg)
|
selectExtensionPackage(sharedPkg, privatePkg)
|
||||||
}
|
}
|
||||||
.toList()
|
.toList()
|
||||||
@@ -163,7 +165,7 @@ internal object ExtensionLoader {
|
|||||||
if (extPkgs.isEmpty()) return emptyList()
|
if (extPkgs.isEmpty()) return emptyList()
|
||||||
|
|
||||||
// Load each extension concurrently and wait for completion
|
// Load each extension concurrently and wait for completion
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
extPkgs
|
extPkgs
|
||||||
.map {
|
.map {
|
||||||
async {
|
async {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
|||||||
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
||||||
import eu.kanade.domain.source.interactor.ToggleLanguage
|
import eu.kanade.domain.source.interactor.ToggleLanguage
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
@@ -49,6 +51,7 @@ class ExtensionFilterViewModel(
|
|||||||
logcat(LogPriority.ERROR, throwable)
|
logcat(LogPriority.ERROR, throwable)
|
||||||
_events.send(ExtensionFilterEvent.FailedFetchingLanguages)
|
_events.send(ExtensionFilterEvent.FailedFetchingLanguages)
|
||||||
}
|
}
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), ExtensionFilterState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), ExtensionFilterState.Loading)
|
||||||
|
|
||||||
fun toggle(language: String) {
|
fun toggle(language: String) {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ class ExtensionsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
@@ -127,7 +126,7 @@ class ExtensionsViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
||||||
viewModelScope.launchIO { findAvailableExtensions() }
|
viewModelScope.launch(Dispatchers.IO) { findAvailableExtensions() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchQueryPredicate(query: String): (Extension) -> Boolean {
|
fun searchQueryPredicate(query: String): (Extension) -> Boolean {
|
||||||
@@ -165,7 +164,7 @@ class ExtensionsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateAllExtensions() {
|
fun updateAllExtensions() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
state.value.items.values.flatten()
|
state.value.items.values.flatten()
|
||||||
.map { it.extension }
|
.map { it.extension }
|
||||||
.filterIsInstance<Extension.Installed>()
|
.filterIsInstance<Extension.Installed>()
|
||||||
@@ -175,13 +174,13 @@ class ExtensionsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun installExtension(extension: Extension.Available) {
|
fun installExtension(extension: Extension.Available) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
extensionManager.installExtension(extension).collectToInstallUpdate(extension)
|
extensionManager.installExtension(extension).collectToInstallUpdate(extension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateExtension(extension: Extension.Installed) {
|
fun updateExtension(extension: Extension.Installed) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
|
extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +210,7 @@ class ExtensionsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun findAvailableExtensions() {
|
fun findAvailableExtensions() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
isRefreshing.update { true }
|
isRefreshing.update { true }
|
||||||
|
|
||||||
extensionManager.findAvailableExtensions()
|
extensionManager.findAvailableExtensions()
|
||||||
|
|||||||
+4
-1
@@ -39,7 +39,9 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||||
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
@@ -145,7 +147,8 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
|
|||||||
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())
|
||||||
|
|
||||||
if (source is ConfigurableSource) {
|
if (source is ConfigurableSource) {
|
||||||
val dataStore = SharedPreferencesDataStore(source.sourcePreferences())
|
val sourcePreferences = withContext(Dispatchers.IO) { source.sourcePreferences().also { it.all } }
|
||||||
|
val dataStore = SharedPreferencesDataStore(sourcePreferences)
|
||||||
preferenceManager.preferenceDataStore = dataStore
|
preferenceManager.preferenceDataStore = dataStore
|
||||||
|
|
||||||
source.setupPreferenceScreen(sourceScreen)
|
source.setupPreferenceScreen(sourceScreen)
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ class MigrateMangaViewModel(
|
|||||||
) { titleList, selection ->
|
) { titleList, selection ->
|
||||||
State(source = source.await(), selection = selection, titleList = titleList)
|
State(source = source.await(), selection = selection, titleList = titleList)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
||||||
|
|
||||||
fun toggleSelection(item: Manga) {
|
fun toggleSelection(item: Manga) {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import eu.kanade.domain.source.interactor.GetLanguagesWithSources
|
|||||||
import eu.kanade.domain.source.interactor.ToggleLanguage
|
import eu.kanade.domain.source.interactor.ToggleLanguage
|
||||||
import eu.kanade.domain.source.interactor.ToggleSource
|
import eu.kanade.domain.source.interactor.ToggleSource
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import tachiyomi.domain.source.model.Source
|
import tachiyomi.domain.source.model.Source
|
||||||
import java.util.SortedMap
|
import java.util.SortedMap
|
||||||
@@ -44,6 +46,7 @@ class SourcesFilterViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.catch<State> { throwable -> emit(State.Error(throwable = throwable)) }
|
.catch<State> { throwable -> emit(State.Error(throwable = throwable)) }
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
||||||
|
|
||||||
fun toggleSource(source: Source) {
|
fun toggleSource(source: Source) {
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceViewModel.Listing
|
|||||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.rounded.FilterList
|
import mihon.icons.materialsymbols.rounded.FilterList
|
||||||
@@ -55,7 +57,6 @@ import mihon.icons.materialsymbols.rounded.Verified
|
|||||||
import mihon.icons.materialsymbols.roundedfilled.Favorite
|
import mihon.icons.materialsymbols.roundedfilled.Favorite
|
||||||
import mihon.presentation.core.util.collectAsLazyPagingItems
|
import mihon.presentation.core.util.collectAsLazyPagingItems
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.source.model.StubSource
|
import tachiyomi.domain.source.model.StubSource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
@@ -224,7 +225,7 @@ data class BrowseSourceScreen(
|
|||||||
onLocalSourceHelpClick = onHelpClick,
|
onLocalSourceHelpClick = onHelpClick,
|
||||||
onMangaClick = { navigator.push((MangaScreen(it.id, true))) },
|
onMangaClick = { navigator.push((MangaScreen(it.id, true))) },
|
||||||
onMangaLongClick = { manga ->
|
onMangaLongClick = { manga ->
|
||||||
scope.launchIO {
|
scope.launch(Dispatchers.IO) {
|
||||||
val duplicates = viewModel.getDuplicateLibraryManga(manga)
|
val duplicates = viewModel.getDuplicateLibraryManga(manga)
|
||||||
when {
|
when {
|
||||||
manga.favorite -> viewModel.setDialog(BrowseSourceViewModel.Dialog.RemoveManga(manga))
|
manga.favorite -> viewModel.setDialog(BrowseSourceViewModel.Dialog.RemoveManga(manga))
|
||||||
|
|||||||
+7
-4
@@ -29,6 +29,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -41,9 +43,9 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
@@ -64,6 +66,7 @@ import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
|
|||||||
class BrowseSourceViewModel(
|
class BrowseSourceViewModel(
|
||||||
@Assisted private val sourceId: Long,
|
@Assisted private val sourceId: Long,
|
||||||
@Assisted listingQuery: String?,
|
@Assisted listingQuery: String?,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
sourcePreferences: SourcePreferences,
|
sourcePreferences: SourcePreferences,
|
||||||
private val libraryPreferences: LibraryPreferences,
|
private val libraryPreferences: LibraryPreferences,
|
||||||
@@ -94,7 +97,7 @@ class BrowseSourceViewModel(
|
|||||||
private val source: Source? get() = state.value.source
|
private val source: Source? get() = state.value.source
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val source = sourceManager.getOrStub(sourceId)
|
val source = sourceManager.getOrStub(sourceId)
|
||||||
|
|
||||||
state.update {
|
state.update {
|
||||||
@@ -234,7 +237,7 @@ class BrowseSourceViewModel(
|
|||||||
* @param manga the manga to update.
|
* @param manga the manga to update.
|
||||||
*/
|
*/
|
||||||
fun changeMangaFavorite(manga: Manga) {
|
fun changeMangaFavorite(manga: Manga) {
|
||||||
viewModelScope.launch {
|
appScope.launch {
|
||||||
val update = if (manga.favorite) {
|
val update = if (manga.favorite) {
|
||||||
val coverLastModified = manga.removeCovers(coverCache).coverLastModified
|
val coverLastModified = manga.removeCovers(coverCache).coverLastModified
|
||||||
MangaUpdate(manga.id) {
|
MangaUpdate(manga.id) {
|
||||||
@@ -307,7 +310,7 @@ class BrowseSourceViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
|
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
setMangaCategories.await(
|
setMangaCategories.await(
|
||||||
mangaId = manga.id,
|
mangaId = manga.id,
|
||||||
categoryIds = categoryIds.toList(),
|
categoryIds = categoryIds.toList(),
|
||||||
|
|||||||
+3
-4
@@ -22,7 +22,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.domain.manga.model.toDomainManga
|
import mihon.domain.manga.model.toDomainManga
|
||||||
import tachiyomi.core.common.preference.toggle
|
import tachiyomi.core.common.preference.toggle
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -139,7 +138,7 @@ abstract class SearchViewModel(
|
|||||||
|
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
|
||||||
searchJob = viewModelScope.launchIO {
|
searchJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
val sources = getSelectedSources()
|
val sources = getSelectedSources()
|
||||||
|
|
||||||
// Reuse previous results if possible
|
// Reuse previous results if possible
|
||||||
@@ -205,8 +204,8 @@ abstract class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setMigrateDialog(currentId: Long, target: Manga) {
|
fun setMigrateDialog(currentId: Long, target: Manga) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val current = getManga.await(currentId) ?: return@launchIO
|
val current = getManga.await(currentId) ?: return@launch
|
||||||
state.update { it.copy(dialog = Dialog.Migrate(target, current)) }
|
state.update { it.copy(dialog = Dialog.Migrate(target, current)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import dev.zacsweers.metro.ContributesIntoMap
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.binding
|
import dev.zacsweers.metro.binding
|
||||||
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
||||||
import tachiyomi.domain.category.interactor.DeleteCategory
|
import tachiyomi.domain.category.interactor.DeleteCategory
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
@@ -32,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||||
class CategoryViewModel(
|
class CategoryViewModel(
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val getCategories: GetCategories,
|
private val getCategories: GetCategories,
|
||||||
private val createCategoryWithName: CreateCategoryWithName,
|
private val createCategoryWithName: CreateCategoryWithName,
|
||||||
private val deleteCategory: DeleteCategory,
|
private val deleteCategory: DeleteCategory,
|
||||||
@@ -66,7 +70,8 @@ class CategoryViewModel(
|
|||||||
|
|
||||||
fun deleteCategory(categoryId: Long) {
|
fun deleteCategory(categoryId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (deleteCategory.await(categoryId = categoryId)) {
|
// The deletion finishes even if the screen is left while it runs
|
||||||
|
when (appScope.async { deleteCategory.await(categoryId = categoryId) }.await()) {
|
||||||
is DeleteCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
|
is DeleteCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import eu.kanade.tachiyomi.source.Source
|
|||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.online.ResolvableSource
|
import eu.kanade.tachiyomi.source.online.ResolvableSource
|
||||||
import eu.kanade.tachiyomi.source.online.UriType
|
import eu.kanade.tachiyomi.source.online.UriType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import mihon.domain.manga.model.toDomainManga
|
import mihon.domain.manga.model.toDomainManga
|
||||||
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||||
@@ -46,7 +47,7 @@ class DeepLinkViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val source = sourceManager.getAll()
|
val source = sourceManager.getAll()
|
||||||
.filterIsInstance<ResolvableSource>()
|
.filterIsInstance<ResolvableSource>()
|
||||||
.firstOrNull { it.getUriType(query) != UriType.Unknown }
|
.firstOrNull { it.getUriType(query) != UriType.Unknown }
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ import eu.kanade.presentation.components.DropdownMenu
|
|||||||
import eu.kanade.presentation.components.NestedMenuItem
|
import eu.kanade.presentation.components.NestedMenuItem
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.automirroredrounded.Sort
|
import mihon.icons.materialsymbols.automirroredrounded.Sort
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Pause
|
import mihon.icons.materialsymbols.roundedfilled.Pause
|
||||||
import mihon.icons.materialsymbols.roundedfilled.PlayArrow
|
import mihon.icons.materialsymbols.roundedfilled.PlayArrow
|
||||||
import tachiyomi.core.common.util.lang.launchUI
|
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.Pill
|
import tachiyomi.presentation.core.components.Pill
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
@@ -258,11 +259,11 @@ object DownloadQueueScreen : Screen() {
|
|||||||
|
|
||||||
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
|
ViewCompat.setNestedScrollingEnabled(viewModel.controllerBinding.root, true)
|
||||||
|
|
||||||
scope.launchUI {
|
scope.launch(Dispatchers.Main) {
|
||||||
viewModel.getDownloadStatusFlow()
|
viewModel.getDownloadStatusFlow()
|
||||||
.collect(viewModel::onStatusChange)
|
.collect(viewModel::onStatusChange)
|
||||||
}
|
}
|
||||||
scope.launchUI {
|
scope.launch(Dispatchers.Main) {
|
||||||
viewModel.getDownloadProgressFlow()
|
viewModel.getDownloadProgressFlow()
|
||||||
.collect(viewModel::onUpdateDownloadedPages)
|
.collect(viewModel::onUpdateDownloadedPages)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
|
|||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
import eu.kanade.tachiyomi.databinding.DownloadListBinding
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -44,6 +46,7 @@ class DownloadQueueViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||||
|
|
||||||
lateinit var controllerBinding: DownloadListBinding
|
lateinit var controllerBinding: DownloadListBinding
|
||||||
@@ -149,19 +152,19 @@ class DownloadQueueViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun pauseDownloads() {
|
fun pauseDownloads() {
|
||||||
downloadManager.pauseDownloads()
|
viewModelScope.launch { downloadManager.pauseDownloads() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearQueue() {
|
fun clearQueue() {
|
||||||
downloadManager.clearQueue()
|
viewModelScope.launch { downloadManager.clearQueue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reorder(downloads: List<Download>) {
|
fun reorder(downloads: List<Download>) {
|
||||||
downloadManager.reorderQueue(downloads)
|
viewModelScope.launch { downloadManager.reorderQueue(downloads) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel(downloads: List<Download>) {
|
fun cancel(downloads: List<Download>) {
|
||||||
downloadManager.cancelQueuedDownloads(downloads)
|
viewModelScope.launch { downloadManager.cancelQueuedDownloads(downloads) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
|
fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R?, reverse: Boolean = false) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import eu.kanade.domain.manga.interactor.UpdateManga
|
|||||||
import eu.kanade.domain.track.interactor.AddTracks
|
import eu.kanade.domain.track.interactor.AddTracks
|
||||||
import eu.kanade.presentation.history.HistoryUiModel
|
import eu.kanade.presentation.history.HistoryUiModel
|
||||||
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -32,10 +33,9 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
@@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||||
class HistoryViewModel(
|
class HistoryViewModel(
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val addTracks: AddTracks,
|
private val addTracks: AddTracks,
|
||||||
private val getCategories: GetCategories,
|
private val getCategories: GetCategories,
|
||||||
private val getDuplicateLibraryManga: GetDuplicateLibraryManga,
|
private val getDuplicateLibraryManga: GetDuplicateLibraryManga,
|
||||||
@@ -88,7 +89,7 @@ class HistoryViewModel(
|
|||||||
_events.send(Event.InternalError)
|
_events.send(Event.InternalError)
|
||||||
}
|
}
|
||||||
.map { it.toHistoryUiModels() }
|
.map { it.toHistoryUiModels() }
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.Default)
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList())
|
||||||
|
|
||||||
@@ -115,11 +116,11 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNextChapter(): Chapter? {
|
suspend fun getNextChapter(): Chapter? {
|
||||||
return withIOContext { getNextChapters.await(onlyUnread = false).firstOrNull() }
|
return getNextChapters.await(onlyUnread = false).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
|
fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false))
|
sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,21 +131,21 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromHistory(history: HistoryWithRelations) {
|
fun removeFromHistory(history: HistoryWithRelations) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
removeHistory.await(history)
|
removeHistory.await(history)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeAllFromHistory(mangaId: Long) {
|
fun removeAllFromHistory(mangaId: Long) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
removeHistory.await(mangaId)
|
removeHistory.await(mangaId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeAllHistory() {
|
fun removeAllHistory() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
val result = removeHistory.awaitAll()
|
val result = removeHistory.awaitAll()
|
||||||
if (!result) return@launchIO
|
if (!result) return@launch
|
||||||
_events.send(Event.HistoryCleared)
|
_events.send(Event.HistoryCleared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,7 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) {
|
private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
setMangaCategories.await(mangaId, categoryIds)
|
setMangaCategories.await(mangaId, categoryIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,7 @@ class HistoryViewModel(
|
|||||||
moveMangaToCategory(manga.id, categories)
|
moveMangaToCategory(manga.id, categories)
|
||||||
if (manga.favorite) return
|
if (manga.favorite) return
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
updateManga.awaitUpdateFavorite(manga.id, true)
|
updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,13 +193,13 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addFavorite(mangaId: Long) {
|
fun addFavorite(mangaId: Long) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
val manga = getManga.await(mangaId) ?: return@launchIO
|
val manga = getManga.await(mangaId) ?: return@launch
|
||||||
|
|
||||||
val duplicates = getDuplicateLibraryManga(manga)
|
val duplicates = getDuplicateLibraryManga(manga)
|
||||||
if (duplicates.isNotEmpty()) {
|
if (duplicates.isNotEmpty()) {
|
||||||
dialog.update { Dialog.DuplicateManga(manga, duplicates) }
|
dialog.update { Dialog.DuplicateManga(manga, duplicates) }
|
||||||
return@launchIO
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
addFavorite(manga)
|
addFavorite(manga)
|
||||||
@@ -206,7 +207,7 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addFavorite(manga: Manga) {
|
fun addFavorite(manga: Manga) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
// Move to default category if applicable
|
// Move to default category if applicable
|
||||||
val categories = getCategories()
|
val categories = getCategories()
|
||||||
val defaultCategoryId = libraryPreferences.defaultCategory.get().toLong()
|
val defaultCategoryId = libraryPreferences.defaultCategory.get().toLong()
|
||||||
@@ -216,14 +217,14 @@ class HistoryViewModel(
|
|||||||
// Default category set
|
// Default category set
|
||||||
defaultCategory != null -> {
|
defaultCategory != null -> {
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launch
|
||||||
moveMangaToCategory(manga.id, defaultCategory)
|
moveMangaToCategory(manga.id, defaultCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatic 'Default' or no categories
|
// Automatic 'Default' or no categories
|
||||||
defaultCategoryId == 0L || categories.isEmpty() -> {
|
defaultCategoryId == 0L || categories.isEmpty() -> {
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launch
|
||||||
moveMangaToCategory(manga.id, null)
|
moveMangaToCategory(manga.id, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +233,7 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync with tracking services if applicable
|
// Sync with tracking services if applicable
|
||||||
addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source))
|
appScope.launch { addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import eu.kanade.domain.base.BasePreferences
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.category.interactor.SetDisplayMode
|
import tachiyomi.domain.category.interactor.SetDisplayMode
|
||||||
import tachiyomi.domain.category.interactor.SetSortModeForCategory
|
import tachiyomi.domain.category.interactor.SetSortModeForCategory
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
@@ -55,7 +55,7 @@ class LibrarySettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setSort(category: Category?, mode: LibrarySort.Type, direction: LibrarySort.Direction) {
|
fun setSort(category: Category?, mode: LibrarySort.Type, direction: LibrarySort.Direction) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
setSortModeForCategory.await(category, mode, direction)
|
setSortModeForCategory.await(category, mode, direction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import mihon.feature.migration.config.MigrationConfigScreen
|
|||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.automirroredrounded.Help
|
import mihon.icons.materialsymbols.automirroredrounded.Help
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -93,7 +92,7 @@ data object LibraryTab : Tab {
|
|||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
val onClickRefresh: (Category?) -> Boolean = { category ->
|
val onClickRefresh: suspend (Category?) -> Boolean = { category ->
|
||||||
val started = LibraryUpdateWorker.startNow(context.workManager, category)
|
val started = LibraryUpdateWorker.startNow(context.workManager, category)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val msgRes = when {
|
val msgRes = when {
|
||||||
@@ -121,8 +120,8 @@ data object LibraryTab : Tab {
|
|||||||
onClickSelectAll = viewModel::selectAll,
|
onClickSelectAll = viewModel::selectAll,
|
||||||
onClickInvertSelection = viewModel::invertSelection,
|
onClickInvertSelection = viewModel::invertSelection,
|
||||||
onClickFilter = viewModel::showSettingsDialog,
|
onClickFilter = viewModel::showSettingsDialog,
|
||||||
onClickRefresh = { onClickRefresh(state.activeCategory) },
|
onClickRefresh = { scope.launch { onClickRefresh(state.activeCategory) } },
|
||||||
onClickGlobalUpdate = { onClickRefresh(null) },
|
onClickGlobalUpdate = { scope.launch { onClickRefresh(null) } },
|
||||||
onClickOpenRandomManga = {
|
onClickOpenRandomManga = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val randomItem = viewModel.getRandomLibraryItemForCurrentCategory()
|
val randomItem = viewModel.getRandomLibraryItemForCurrentCategory()
|
||||||
@@ -189,7 +188,7 @@ data object LibraryTab : Tab {
|
|||||||
onChangeCurrentPage = viewModel::updateActiveCategoryIndex,
|
onChangeCurrentPage = viewModel::updateActiveCategoryIndex,
|
||||||
onClickManga = { navigator.push(MangaScreen(it)) },
|
onClickManga = { navigator.push(MangaScreen(it)) },
|
||||||
onContinueReadingClicked = { it: LibraryManga ->
|
onContinueReadingClicked = { it: LibraryManga ->
|
||||||
scope.launchIO {
|
scope.launch {
|
||||||
val chapter = viewModel.getNextUnreadChapter(it.manga)
|
val chapter = viewModel.getNextUnreadChapter(it.manga)
|
||||||
if (chapter != null) {
|
if (chapter != null) {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import eu.kanade.tachiyomi.source.model.SManga
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -42,14 +43,15 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.core.common.utils.mutate
|
import mihon.core.common.utils.mutate
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import mihon.domain.library.model.search.QueryNode
|
import mihon.domain.library.model.search.QueryNode
|
||||||
import mihon.feature.library.matches
|
import mihon.feature.library.matches
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.util.lang.compareToWithCollator
|
import tachiyomi.core.common.util.lang.compareToWithCollator
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
import tachiyomi.domain.category.model.Category
|
import tachiyomi.domain.category.model.Category
|
||||||
@@ -77,6 +79,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||||
class LibraryViewModel(
|
class LibraryViewModel(
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val getLibraryManga: GetLibraryManga,
|
private val getLibraryManga: GetLibraryManga,
|
||||||
private val getCategories: GetCategories,
|
private val getCategories: GetCategories,
|
||||||
private val getTracksPerManga: GetTracksPerManga,
|
private val getTracksPerManga: GetTracksPerManga,
|
||||||
@@ -175,7 +178,7 @@ class LibraryViewModel(
|
|||||||
.applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds),
|
.applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
@@ -482,7 +485,8 @@ class LibraryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNextUnreadChapter(manga: Manga): Chapter? {
|
suspend fun getNextUnreadChapter(manga: Manga): Chapter? {
|
||||||
return getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true).getNextUnread(manga, downloadManager)
|
val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true)
|
||||||
|
return withContext(Dispatchers.Default) { chapters.getNextUnread(manga, downloadManager) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,7 +506,7 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
private fun downloadNextChapters(amount: Int?) {
|
private fun downloadNextChapters(amount: Int?) {
|
||||||
val mangas = selectedManga
|
val mangas = selectedManga
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
mangas.forEach { manga ->
|
mangas.forEach { manga ->
|
||||||
val chapters = getNextChapters.await(manga.id)
|
val chapters = getNextChapters.await(manga.id)
|
||||||
.fastFilterNot { chapter ->
|
.fastFilterNot { chapter ->
|
||||||
@@ -524,7 +528,7 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
private fun downloadBookmarkedChapters() {
|
private fun downloadBookmarkedChapters() {
|
||||||
val mangas = selectedManga
|
val mangas = selectedManga
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
mangas.forEach { manga ->
|
mangas.forEach { manga ->
|
||||||
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
|
val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
|
||||||
.fastFilterNot { chapter ->
|
.fastFilterNot { chapter ->
|
||||||
@@ -547,7 +551,7 @@ class LibraryViewModel(
|
|||||||
*/
|
*/
|
||||||
fun markReadSelection(read: Boolean) {
|
fun markReadSelection(read: Boolean) {
|
||||||
val selection = selectedManga
|
val selection = selectedManga
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
selection.forEach { manga ->
|
selection.forEach { manga ->
|
||||||
setReadStatus.await(
|
setReadStatus.await(
|
||||||
manga = manga,
|
manga = manga,
|
||||||
@@ -566,7 +570,7 @@ class LibraryViewModel(
|
|||||||
* @param deleteChapters whether to delete downloaded chapters.
|
* @param deleteChapters whether to delete downloaded chapters.
|
||||||
*/
|
*/
|
||||||
fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
if (deleteFromLibrary) {
|
if (deleteFromLibrary) {
|
||||||
val toDelete = mangas.map {
|
val toDelete = mangas.map {
|
||||||
it.removeCovers(coverCache)
|
it.removeCovers(coverCache)
|
||||||
@@ -596,7 +600,7 @@ class LibraryViewModel(
|
|||||||
* @param removeCategories the categories to remove in all mangas.
|
* @param removeCategories the categories to remove in all mangas.
|
||||||
*/
|
*/
|
||||||
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
|
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
mangaList.forEach { manga ->
|
mangaList.forEach { manga ->
|
||||||
val categoryIds = getCategoryIds(manga)
|
val categoryIds = getCategoryIds(manga)
|
||||||
.subtract(removeCategories.toSet())
|
.subtract(removeCategories.toSet())
|
||||||
@@ -721,7 +725,7 @@ class LibraryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openChangeCategoryDialog() {
|
fun openChangeCategoryDialog() {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
// Create a copy of selected manga
|
// Create a copy of selected manga
|
||||||
val mangaList = selectedManga
|
val mangaList = selectedManga
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import eu.kanade.tachiyomi.util.system.isBenchmarkBuildType
|
|||||||
import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
|
import eu.kanade.tachiyomi.util.system.isNavigationBarNeedsScrim
|
||||||
import eu.kanade.tachiyomi.util.system.updaterEnabled
|
import eu.kanade.tachiyomi.util.system.updaterEnabled
|
||||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
@@ -115,7 +116,6 @@ import mihon.icons.materialsymbols.MaterialSymbols
|
|||||||
import mihon.icons.materialsymbols.automirroredrounded.OpenInNew
|
import mihon.icons.materialsymbols.automirroredrounded.OpenInNew
|
||||||
import mihon.icons.materialsymbols.roundedfilled.VolunteerActivism
|
import mihon.icons.materialsymbols.roundedfilled.VolunteerActivism
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
||||||
@@ -302,7 +302,7 @@ class MainActivity : BaseActivity() {
|
|||||||
setSplashScreenExitAnimation(splashScreen)
|
setSplashScreenExitAnimation(splashScreen)
|
||||||
|
|
||||||
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
|
if (isLaunch && libraryPreferences.autoClearChapterCache.get()) {
|
||||||
lifecycleScope.launchIO {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
chapterCache.clear()
|
chapterCache.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,9 @@ import kotlinx.coroutines.flow.WhileSubscribed
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -87,7 +85,7 @@ class MangaCoverViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val uri = saveCoverInternal(context, temp = true) ?: return@launch
|
val uri = saveCoverInternal(context, temp = true) ?: return@launch
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.startActivity(uri.toShareIntent(context))
|
context.startActivity(uri.toShareIntent(context))
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -113,11 +111,11 @@ class MangaCoverViewModel(
|
|||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return withIOContext {
|
return withContext(Dispatchers.IO) {
|
||||||
val result = context.imageLoader.execute(req).image?.asDrawable(context.resources)
|
val result = context.imageLoader.execute(req).image?.asDrawable(context.resources)
|
||||||
|
|
||||||
// TODO: Handle animated cover
|
// TODO: Handle animated cover
|
||||||
val bitmap = result?.getBitmapOrNull() ?: return@withIOContext null
|
val bitmap = result?.getBitmapOrNull() ?: return@withContext null
|
||||||
imageSaver.save(
|
imageSaver.save(
|
||||||
Image.Cover(
|
Image.Cover(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
@@ -136,7 +134,7 @@ class MangaCoverViewModel(
|
|||||||
*/
|
*/
|
||||||
fun editCover(context: Context, data: Uri) {
|
fun editCover(context: Context, data: Uri) {
|
||||||
val manga = state.value ?: return
|
val manga = state.value ?: return
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
context.contentResolver.openInputStream(data)?.use {
|
context.contentResolver.openInputStream(data)?.use {
|
||||||
try {
|
try {
|
||||||
manga.editCover(coverManager, it, updateManga, coverCache)
|
manga.editCover(coverManager, it, updateManga, coverCache)
|
||||||
@@ -149,7 +147,7 @@ class MangaCoverViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteCustomCover(context: Context) {
|
fun deleteCustomCover(context: Context) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
coverCache.deleteCustomCover(mangaId)
|
coverCache.deleteCustomCover(mangaId)
|
||||||
updateManga.awaitUpdateCoverLastModified(mangaId)
|
updateManga.awaitUpdateCoverLastModified(mangaId)
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
|||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.feature.migration.config.MigrationConfigScreen
|
import mihon.feature.migration.config.MigrationConfigScreen
|
||||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.model.Chapter
|
import tachiyomi.domain.chapter.model.Chapter
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
@@ -94,7 +95,7 @@ class MangaScreen(
|
|||||||
LaunchedEffect(successState.manga, viewModel.source) {
|
LaunchedEffect(successState.manga, viewModel.source) {
|
||||||
if (isHttpSource) {
|
if (isHttpSource) {
|
||||||
try {
|
try {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
assistUrl = getMangaUrl(viewModel.manga, viewModel.source)
|
assistUrl = getMangaUrl(viewModel.manga, viewModel.source)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
|||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -62,20 +63,20 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
import mihon.domain.chapter.interactor.FilterChaptersForDownload
|
||||||
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
import mihon.domain.source.interactor.UpdateMangaFromRemote
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.preference.CheckboxState
|
import tachiyomi.core.common.preference.CheckboxState
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.preference.mapAsCheckboxState
|
import tachiyomi.core.common.preference.mapAsCheckboxState
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||||
@@ -105,6 +106,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
class MangaViewModel(
|
class MangaViewModel(
|
||||||
@Assisted private val mangaId: Long,
|
@Assisted private val mangaId: Long,
|
||||||
@Assisted private val isFromSource: Boolean,
|
@Assisted private val isFromSource: Boolean,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val libraryPreferences: LibraryPreferences,
|
private val libraryPreferences: LibraryPreferences,
|
||||||
trackPreferences: TrackPreferences,
|
trackPreferences: TrackPreferences,
|
||||||
@@ -190,7 +192,7 @@ class MangaViewModel(
|
|||||||
|
|
||||||
private var hasPromptedToAddBefore = false
|
private var hasPromptedToAddBefore = false
|
||||||
|
|
||||||
private val defaultChapterFlagsJob = viewModelScope.launchIO {
|
private val defaultChapterFlagsJob = viewModelScope.launch {
|
||||||
val manga = getMangaAndChapters.awaitManga(mangaId)
|
val manga = getMangaAndChapters.awaitManga(mangaId)
|
||||||
if (!manga.favorite) {
|
if (!manga.favorite) {
|
||||||
setMangaDefaultChapterFlags.await(manga)
|
setMangaDefaultChapterFlags.await(manga)
|
||||||
@@ -271,19 +273,26 @@ class MangaViewModel(
|
|||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
hideMissingChapters = hideMissingChapters,
|
hideMissingChapters = hideMissingChapters,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// Composition would otherwise build these on first read, on the main thread, for every download progress tick
|
||||||
|
.onEach {
|
||||||
|
it.processedChapters
|
||||||
|
it.chapterListItems
|
||||||
|
it.isAnySelected
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
// Progress ticks often, and each one copies the map of download states
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
||||||
.filter { it.manga.id == mangaId }
|
.filter { it.manga.id == mangaId }
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.collect(::updateDownloadState)
|
.collect(::updateDownloadState)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
// The state loads the entry and its chapters anyway, so they aren't queried separately
|
// The state loads the entry and its chapters anyway, so they aren't queried separately
|
||||||
val loadedState = state.filterIsInstance<State.Success>().first()
|
val loadedState = state.filterIsInstance<State.Success>().first()
|
||||||
val manga = loadedState.manga
|
val manga = loadedState.manga
|
||||||
@@ -324,7 +333,6 @@ class MangaViewModel(
|
|||||||
fetchChapters: Boolean,
|
fetchChapters: Boolean,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
withUIContext {
|
|
||||||
val update = updateMangaFromRemote(
|
val update = updateMangaFromRemote(
|
||||||
source = sourceManager.getOrStub(manga.source),
|
source = sourceManager.getOrStub(manga.source),
|
||||||
manga = manga,
|
manga = manga,
|
||||||
@@ -337,7 +345,6 @@ class MangaViewModel(
|
|||||||
if (manualFetch) {
|
if (manualFetch) {
|
||||||
downloadNewChapters(update.newChapters)
|
downloadNewChapters(update.newChapters)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
// ignore
|
// ignore
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -382,7 +389,7 @@ class MangaViewModel(
|
|||||||
checkDuplicate: Boolean = true,
|
checkDuplicate: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val state = successState ?: return
|
val state = successState ?: return
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val manga = state.manga
|
val manga = state.manga
|
||||||
|
|
||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
@@ -392,7 +399,7 @@ class MangaViewModel(
|
|||||||
if (manga.removeCovers(coverCache) != manga) {
|
if (manga.removeCovers(coverCache) != manga) {
|
||||||
updateManga.awaitUpdateCoverLastModified(manga.id)
|
updateManga.awaitUpdateCoverLastModified(manga.id)
|
||||||
}
|
}
|
||||||
withUIContext { onRemoved() }
|
onRemoved()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add to library
|
// Add to library
|
||||||
@@ -402,7 +409,7 @@ class MangaViewModel(
|
|||||||
|
|
||||||
if (duplicates.isNotEmpty()) {
|
if (duplicates.isNotEmpty()) {
|
||||||
dialog.value = Dialog.DuplicateManga(manga, duplicates)
|
dialog.value = Dialog.DuplicateManga(manga, duplicates)
|
||||||
return@launchIO
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,14 +421,14 @@ class MangaViewModel(
|
|||||||
// Default category set
|
// Default category set
|
||||||
defaultCategory != null -> {
|
defaultCategory != null -> {
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launch
|
||||||
moveMangaToCategory(defaultCategory)
|
moveMangaToCategory(defaultCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatic 'Default' or no categories
|
// Automatic 'Default' or no categories
|
||||||
defaultCategoryId == 0L || categories.isEmpty() -> {
|
defaultCategoryId == 0L || categories.isEmpty() -> {
|
||||||
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
val result = updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
if (!result) return@launchIO
|
if (!result) return@launch
|
||||||
moveMangaToCategory(null)
|
moveMangaToCategory(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +437,7 @@ class MangaViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finally match with enhanced tracking when available
|
// Finally match with enhanced tracking when available
|
||||||
addTracks.bindEnhancedTrackers(manga, state.source)
|
appScope.launch { addTracks.bindEnhancedTrackers(manga, state.source) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,7 +460,7 @@ class MangaViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setFetchInterval(manga: Manga, interval: Int) {
|
fun setFetchInterval(manga: Manga, interval: Int) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
updateManga.awaitUpdateFetchInterval(
|
updateManga.awaitUpdateFetchInterval(
|
||||||
// Custom intervals are negative
|
// Custom intervals are negative
|
||||||
manga.copy(fetchInterval = -interval),
|
manga.copy(fetchInterval = -interval),
|
||||||
@@ -474,7 +481,7 @@ class MangaViewModel(
|
|||||||
*/
|
*/
|
||||||
private fun deleteDownloads() {
|
private fun deleteDownloads() {
|
||||||
val state = successState ?: return
|
val state = successState ?: return
|
||||||
downloadManager.deleteManga(state.manga, state.source)
|
appScope.launch { downloadManager.deleteManga(state.manga, state.source) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -501,7 +508,7 @@ class MangaViewModel(
|
|||||||
moveMangaToCategory(categories)
|
moveMangaToCategory(categories)
|
||||||
if (manga.favorite) return
|
if (manga.favorite) return
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
updateManga.awaitUpdateFavorite(manga.id, true)
|
updateManga.awaitUpdateFavorite(manga.id, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,7 +524,7 @@ class MangaViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun moveMangaToCategory(categoryIds: List<Long>) {
|
private fun moveMangaToCategory(categoryIds: List<Long>) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
setMangaCategories.await(mangaId, categoryIds)
|
setMangaCategories.await(mangaId, categoryIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,15 +652,18 @@ class MangaViewModel(
|
|||||||
startNow: Boolean,
|
startNow: Boolean,
|
||||||
) {
|
) {
|
||||||
if (successState == null) return
|
if (successState == null) return
|
||||||
|
val startNowId = if (startNow) chapters.singleOrNull()?.id ?: return else null
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launch {
|
||||||
if (startNow) {
|
// The queueing outlives the screen, the add to library prompt doesn't
|
||||||
val chapterId = chapters.singleOrNull()?.id ?: return@launchNonCancellable
|
appScope.launch {
|
||||||
downloadManager.startDownloadNow(chapterId)
|
if (startNowId != null) {
|
||||||
|
downloadManager.startDownloadNow(startNowId)
|
||||||
} else {
|
} else {
|
||||||
downloadChapters(chapters)
|
downloadChapters(chapters)
|
||||||
toggleAllSelection(false)
|
|
||||||
}
|
}
|
||||||
|
}.join()
|
||||||
|
if (startNowId == null) toggleAllSelection(false)
|
||||||
|
|
||||||
if (!isFavorited && !hasPromptedToAddBefore) {
|
if (!isFavorited && !hasPromptedToAddBefore) {
|
||||||
hasPromptedToAddBefore = true
|
hasPromptedToAddBefore = true
|
||||||
@@ -710,9 +720,11 @@ class MangaViewModel(
|
|||||||
|
|
||||||
private fun cancelDownload(chapterId: Long) {
|
private fun cancelDownload(chapterId: Long) {
|
||||||
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
|
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
downloadManager.cancelQueuedDownloads(listOf(activeDownload))
|
downloadManager.cancelQueuedDownloads(listOf(activeDownload))
|
||||||
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun markPreviousChapterRead(pointer: Chapter) {
|
fun markPreviousChapterRead(pointer: Chapter) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
@@ -730,14 +742,17 @@ class MangaViewModel(
|
|||||||
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
|
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
|
||||||
toggleAllSelection(false)
|
toggleAllSelection(false)
|
||||||
if (chapters.isEmpty()) return
|
if (chapters.isEmpty()) return
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
|
// The read state is written even if the screen closes, the tracking prompt isn't
|
||||||
|
appScope.launch {
|
||||||
setReadStatus.await(
|
setReadStatus.await(
|
||||||
read = read,
|
read = read,
|
||||||
chapters = chapters.toTypedArray(),
|
chapters = chapters.toTypedArray(),
|
||||||
)
|
)
|
||||||
|
}.join()
|
||||||
|
|
||||||
if (!read || successState?.hasLoggedInTrackers == false || autoTrackState == AutoTrackState.NEVER) {
|
if (!read || successState?.hasLoggedInTrackers == false || autoTrackState == AutoTrackState.NEVER) {
|
||||||
return@launchIO
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTrackers()
|
refreshTrackers()
|
||||||
@@ -746,13 +761,11 @@ class MangaViewModel(
|
|||||||
val maxChapterNumber = chapters.maxOf { it.chapterNumber }
|
val maxChapterNumber = chapters.maxOf { it.chapterNumber }
|
||||||
val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead }
|
val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead }
|
||||||
|
|
||||||
if (!shouldPromptTrackingUpdate) return@launchIO
|
if (!shouldPromptTrackingUpdate) return@launch
|
||||||
if (autoTrackState == AutoTrackState.ALWAYS) {
|
if (autoTrackState == AutoTrackState.ALWAYS) {
|
||||||
trackChapter.await(context, mangaId, maxChapterNumber)
|
trackChapter.await(context, mangaId, maxChapterNumber)
|
||||||
withUIContext {
|
|
||||||
context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt()))
|
context.toast(context.stringResource(MR.strings.trackers_updated_summary, maxChapterNumber.toInt()))
|
||||||
}
|
return@launch
|
||||||
return@launchIO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = snackbarHostState.showSnackbar(
|
val result = snackbarHostState.showSnackbar(
|
||||||
@@ -775,7 +788,7 @@ class MangaViewModel(
|
|||||||
logcat(LogPriority.ERROR, e) {
|
logcat(LogPriority.ERROR, e) {
|
||||||
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
|
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
|
||||||
}
|
}
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.toast(
|
context.toast(
|
||||||
context.stringResource(
|
context.stringResource(
|
||||||
MR.strings.track_error,
|
MR.strings.track_error,
|
||||||
@@ -801,7 +814,7 @@ class MangaViewModel(
|
|||||||
* @param chapters the list of chapters to bookmark.
|
* @param chapters the list of chapters to bookmark.
|
||||||
*/
|
*/
|
||||||
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
|
fun bookmarkChapters(chapters: List<Chapter>, bookmarked: Boolean) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
chapters
|
chapters
|
||||||
.filterNot { it.bookmark == bookmarked }
|
.filterNot { it.bookmark == bookmarked }
|
||||||
.map { ChapterUpdate(it.id) { bookmark = bookmarked } }
|
.map { ChapterUpdate(it.id) { bookmark = bookmarked } }
|
||||||
@@ -816,24 +829,20 @@ class MangaViewModel(
|
|||||||
* @param chapters the list of chapters to delete.
|
* @param chapters the list of chapters to delete.
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<Chapter>) {
|
fun deleteChapters(chapters: List<Chapter>) {
|
||||||
viewModelScope.launchNonCancellable {
|
val state = successState ?: return
|
||||||
|
// Outlives the screen, which the delete dialog usually closes along with
|
||||||
|
appScope.launch {
|
||||||
try {
|
try {
|
||||||
successState?.let { state ->
|
downloadManager.deleteChapters(chapters, state.manga, state.source)
|
||||||
downloadManager.deleteChapters(
|
} catch (e: Exception) {
|
||||||
chapters,
|
|
||||||
state.manga,
|
|
||||||
state.source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadNewChapters(chapters: List<Chapter>) {
|
private fun downloadNewChapters(chapters: List<Chapter>) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
val manga = successState?.manga ?: return@launchNonCancellable
|
val manga = successState?.manga ?: return@launch
|
||||||
val chaptersToDownload = filterChaptersForDownload.await(manga, chapters)
|
val chaptersToDownload = filterChaptersForDownload.await(manga, chapters)
|
||||||
|
|
||||||
if (chaptersToDownload.isNotEmpty()) {
|
if (chaptersToDownload.isNotEmpty()) {
|
||||||
@@ -854,7 +863,7 @@ class MangaViewModel(
|
|||||||
TriState.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD
|
TriState.ENABLED_IS -> Manga.CHAPTER_SHOW_UNREAD
|
||||||
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ
|
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_READ
|
||||||
}
|
}
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
setMangaChapterFlags.awaitSetUnreadFilter(manga, flag)
|
setMangaChapterFlags.awaitSetUnreadFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,7 +881,7 @@ class MangaViewModel(
|
|||||||
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED
|
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_DOWNLOADED
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag)
|
setMangaChapterFlags.awaitSetDownloadedFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -890,7 +899,7 @@ class MangaViewModel(
|
|||||||
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED
|
TriState.ENABLED_NOT -> Manga.CHAPTER_SHOW_NOT_BOOKMARKED
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag)
|
setMangaChapterFlags.awaitSetBookmarkFilter(manga, flag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,7 +911,7 @@ class MangaViewModel(
|
|||||||
fun setDisplayMode(mode: Long) {
|
fun setDisplayMode(mode: Long) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
setMangaChapterFlags.awaitSetDisplayMode(manga, mode)
|
setMangaChapterFlags.awaitSetDisplayMode(manga, mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,25 +923,27 @@ class MangaViewModel(
|
|||||||
fun setSorting(sort: Long) {
|
fun setSorting(sort: Long) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort)
|
setMangaChapterFlags.awaitSetSortingModeOrFlipOrder(manga, sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCurrentSettingsAsDefault(applyToExisting: Boolean) {
|
fun setCurrentSettingsAsDefault(applyToExisting: Boolean) {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launch {
|
||||||
|
appScope.launch {
|
||||||
libraryPreferences.setChapterSettingsDefault(manga)
|
libraryPreferences.setChapterSettingsDefault(manga)
|
||||||
if (applyToExisting) {
|
if (applyToExisting) {
|
||||||
setMangaDefaultChapterFlags.awaitAll()
|
setMangaDefaultChapterFlags.awaitAll()
|
||||||
}
|
}
|
||||||
|
}.join()
|
||||||
snackbarHostState.showSnackbar(message = context.stringResource(MR.strings.chapter_settings_updated))
|
snackbarHostState.showSnackbar(message = context.stringResource(MR.strings.chapter_settings_updated))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetToDefaultSettings() {
|
fun resetToDefaultSettings() {
|
||||||
val manga = successState?.manga ?: return
|
val manga = successState?.manga ?: return
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launch {
|
||||||
setMangaDefaultChapterFlags.await(manga)
|
setMangaDefaultChapterFlags.await(manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1057,7 @@ class MangaViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setExcludedScanlators(excludedScanlators: Set<String>) {
|
fun setExcludedScanlators(excludedScanlators: Set<String>) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
setExcludedScanlators.await(mangaId, excludedScanlators)
|
setExcludedScanlators.await(mangaId, excludedScanlators)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1127,11 +1138,12 @@ class MangaViewModel(
|
|||||||
val unreadFilter = manga.unreadFilter
|
val unreadFilter = manga.unreadFilter
|
||||||
val downloadedFilter = manga.downloadedFilter
|
val downloadedFilter = manga.downloadedFilter
|
||||||
val bookmarkedFilter = manga.bookmarkedFilter
|
val bookmarkedFilter = manga.bookmarkedFilter
|
||||||
|
val chapterSort = getChapterSort(manga)
|
||||||
return asSequence()
|
return asSequence()
|
||||||
.filter { (chapter) -> applyFilter(unreadFilter) { !chapter.read } }
|
.filter { (chapter) -> applyFilter(unreadFilter) { !chapter.read } }
|
||||||
.filter { (chapter) -> applyFilter(bookmarkedFilter) { chapter.bookmark } }
|
.filter { (chapter) -> applyFilter(bookmarkedFilter) { chapter.bookmark } }
|
||||||
.filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalManga } }
|
.filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalManga } }
|
||||||
.sortedWith { (chapter1), (chapter2) -> getChapterSort(manga).invoke(chapter1, chapter2) }
|
.sortedWith { (chapter1), (chapter2) -> chapterSort(chapter1, chapter2) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey
|
|||||||
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
|
import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel
|
||||||
import eu.kanade.presentation.manga.MangaNotesScreen
|
import eu.kanade.presentation.manga.MangaNotesScreen
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
import kotlinx.coroutines.launch
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.domain.manga.interactor.UpdateMangaNotes
|
import tachiyomi.domain.manga.interactor.UpdateMangaNotes
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ class MangaNotesScreen(
|
|||||||
@AssistedInject
|
@AssistedInject
|
||||||
class Model(
|
class Model(
|
||||||
@Assisted private val manga: Manga,
|
@Assisted private val manga: Manga,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val updateMangaNotes: UpdateMangaNotes,
|
private val updateMangaNotes: UpdateMangaNotes,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -65,7 +69,8 @@ class MangaNotesScreen(
|
|||||||
it.copy(notes = content)
|
it.copy(notes = content)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
// On Main so the debounced saves and the one on leaving are written in order
|
||||||
|
appScope.launch(Dispatchers.Main.immediate) {
|
||||||
updateMangaNotes(manga.id, content)
|
updateMangaNotes(manga.id, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
|
|||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -67,16 +69,15 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import mihon.icons.materialsymbols.MaterialSymbols
|
import mihon.icons.materialsymbols.MaterialSymbols
|
||||||
import mihon.icons.materialsymbols.roundedfilled.Delete
|
import mihon.icons.materialsymbols.roundedfilled.Delete
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
import tachiyomi.domain.source.service.SourceManager
|
||||||
@@ -201,6 +202,7 @@ data class TrackInfoDialogHomeScreen(
|
|||||||
class Model(
|
class Model(
|
||||||
@Assisted private val mangaId: Long,
|
@Assisted private val mangaId: Long,
|
||||||
@Assisted private val sourceId: Long,
|
@Assisted private val sourceId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val getTracks: GetTracks,
|
private val getTracks: GetTracks,
|
||||||
private val getManga: GetManga,
|
private val getManga: GetManga,
|
||||||
@@ -235,13 +237,13 @@ data class TrackInfoDialogHomeScreen(
|
|||||||
|
|
||||||
fun registerEnhancedTracking(item: TrackItem) {
|
fun registerEnhancedTracking(item: TrackItem) {
|
||||||
item.tracker as EnhancedTracker
|
item.tracker as EnhancedTracker
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
val manga = getManga.await(mangaId) ?: return@launchNonCancellable
|
val manga = getManga.await(mangaId) ?: return@launch
|
||||||
try {
|
try {
|
||||||
val matchResult = item.tracker.match(manga) ?: throw Exception()
|
val matchResult = item.tracker.match(manga) ?: throw Exception()
|
||||||
item.tracker.register(matchResult, mangaId)
|
item.tracker.register(matchResult, mangaId)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.toast(MR.strings.error_no_match)
|
context.toast(MR.strings.error_no_match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,7 @@ data class TrackInfoDialogHomeScreen(
|
|||||||
logcat(LogPriority.ERROR, e) {
|
logcat(LogPriority.ERROR, e) {
|
||||||
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
|
"Failed to refresh track data mangaId=$mangaId for service ${track!!.id}"
|
||||||
}
|
}
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
context.toast(
|
context.toast(
|
||||||
context.stringResource(
|
context.stringResource(
|
||||||
MR.strings.track_error,
|
MR.strings.track_error,
|
||||||
@@ -268,7 +270,7 @@ data class TrackInfoDialogHomeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun togglePrivate(item: TrackItem) {
|
fun togglePrivate(item: TrackItem) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
item.tracker.setRemotePrivate(item.track!!.toDbTrack(), !item.track.private)
|
item.tracker.setRemotePrivate(item.track!!.toDbTrack(), !item.track.private)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +318,7 @@ data class TrackStatusSelectorScreen(
|
|||||||
class Model(
|
class Model(
|
||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -340,7 +343,7 @@ data class TrackStatusSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setStatus() {
|
fun setStatus() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
tracker.setRemoteStatus(track.toDbTrack(), state.value.selection)
|
tracker.setRemoteStatus(track.toDbTrack(), state.value.selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,6 +382,7 @@ data class TrackChapterSelectorScreen(
|
|||||||
class Model(
|
class Model(
|
||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -408,7 +412,7 @@ data class TrackChapterSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setChapter() {
|
fun setChapter() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
tracker.setRemoteLastChapterRead(track.toDbTrack(), state.value.selection)
|
tracker.setRemoteLastChapterRead(track.toDbTrack(), state.value.selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +451,7 @@ data class TrackScoreSelectorScreen(
|
|||||||
class Model(
|
class Model(
|
||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -475,7 +480,7 @@ data class TrackScoreSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setScore() {
|
fun setScore() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
tracker.setRemoteScore(track.toDbTrack(), state.value.selection)
|
tracker.setRemoteScore(track.toDbTrack(), state.value.selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,6 +579,7 @@ data class TrackDateSelectorScreen(
|
|||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
@Assisted private val start: Boolean,
|
@Assisted private val start: Boolean,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -597,7 +603,7 @@ data class TrackDateSelectorScreen(
|
|||||||
fun setDate(millis: Long) {
|
fun setDate(millis: Long) {
|
||||||
// Convert to local time
|
// Convert to local time
|
||||||
val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault())
|
val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault())
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
if (start) {
|
if (start) {
|
||||||
tracker.setRemoteStartDate(track.toDbTrack(), localMillis)
|
tracker.setRemoteStartDate(track.toDbTrack(), localMillis)
|
||||||
} else {
|
} else {
|
||||||
@@ -678,6 +684,7 @@ data class TrackDateRemoverScreen(
|
|||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
@Assisted private val start: Boolean,
|
@Assisted private val start: Boolean,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -693,7 +700,7 @@ data class TrackDateRemoverScreen(
|
|||||||
fun getServiceName() = tracker.name
|
fun getServiceName() = tracker.name
|
||||||
|
|
||||||
fun removeDate() {
|
fun removeDate() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
if (start) {
|
if (start) {
|
||||||
tracker.setRemoteStartDate(track.toDbTrack(), 0)
|
tracker.setRemoteStartDate(track.toDbTrack(), 0)
|
||||||
} else {
|
} else {
|
||||||
@@ -749,6 +756,7 @@ data class TrackerSearchScreen(
|
|||||||
@Assisted private val currentUrl: String?,
|
@Assisted private val currentUrl: String?,
|
||||||
@Assisted initialQuery: String,
|
@Assisted initialQuery: String,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -783,7 +791,7 @@ data class TrackerSearchScreen(
|
|||||||
// To show loading state
|
// To show loading state
|
||||||
state.update { it.copy(queryResult = null, selected = null) }
|
state.update { it.copy(queryResult = null, selected = null) }
|
||||||
|
|
||||||
val result = withIOContext {
|
val result = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val results = tracker.search(query)
|
val results = tracker.search(query)
|
||||||
Result.success(results)
|
Result.success(results)
|
||||||
@@ -801,7 +809,7 @@ data class TrackerSearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun registerTracking(item: TrackSearch) {
|
fun registerTracking(item: TrackSearch) {
|
||||||
viewModelScope.launchNonCancellable { tracker.register(item, mangaId) }
|
appScope.launch { tracker.register(item, mangaId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSelection(selected: TrackSearch) {
|
fun updateSelection(selected: TrackSearch) {
|
||||||
@@ -895,6 +903,7 @@ data class TrackerRemoveScreen(
|
|||||||
@Assisted private val mangaId: Long,
|
@Assisted private val mangaId: Long,
|
||||||
@Assisted private val track: Track,
|
@Assisted private val track: Track,
|
||||||
@Assisted private val trackerId: Long,
|
@Assisted private val trackerId: Long,
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val deleteTrack: DeleteTrack,
|
private val deleteTrack: DeleteTrack,
|
||||||
trackerManager: TrackerManager,
|
trackerManager: TrackerManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -913,7 +922,7 @@ data class TrackerRemoveScreen(
|
|||||||
fun isDeletable() = tracker is DeletableTracker
|
fun isDeletable() = tracker is DeletableTracker
|
||||||
|
|
||||||
fun deleteMangaFromService() {
|
fun deleteMangaFromService() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
try {
|
try {
|
||||||
(tracker as DeletableTracker).delete(track)
|
(tracker as DeletableTracker).delete(track)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -923,7 +932,7 @@ data class TrackerRemoveScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterTracking(serviceId: Long) {
|
fun unregisterTracking(serviceId: Long) {
|
||||||
viewModelScope.launchNonCancellable { deleteTrack.await(mangaId, serviceId) }
|
appScope.launch { deleteTrack.await(mangaId, serviceId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import mihon.feature.support.SupportUsScreen
|
import mihon.feature.support.SupportUsScreen
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class MoreViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
// Handle running/paused status change and queue progress updating
|
// Handle running/paused status change and queue progress updating
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
downloadManager.isDownloaderRunning,
|
downloadManager.isDownloaderRunning,
|
||||||
downloadManager.queueState,
|
downloadManager.queueState,
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
|
|||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ class NewUpdateScreenModel(
|
|||||||
downloadJob = viewModelScope.launch {
|
downloadJob = viewModelScope.launch {
|
||||||
state.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) }
|
state.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) }
|
||||||
try {
|
try {
|
||||||
withIOContext { downloadApk() }
|
withContext(Dispatchers.IO) { downloadApk() }
|
||||||
state.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) }
|
state.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) }
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import eu.kanade.tachiyomi.util.system.readerBackgroundColor
|
|||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
@@ -95,8 +96,6 @@ import mihon.app.di.AppGraph
|
|||||||
import mihon.core.metro.metroGraph
|
import mihon.core.metro.metroGraph
|
||||||
import tachiyomi.core.common.Constants
|
import tachiyomi.core.common.Constants
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.util.collectAsState
|
import tachiyomi.presentation.core.util.collectAsState
|
||||||
@@ -345,9 +344,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
lifecycleScope.launchNonCancellable {
|
viewModel.saveHistory()
|
||||||
viewModel.updateHistory()
|
|
||||||
}
|
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +618,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
binding.readerContainer.removeView(loadingIndicator)
|
binding.readerContainer.removeView(loadingIndicator)
|
||||||
viewModel.state.value.viewer?.setChapters(viewerChapters)
|
viewModel.state.value.viewer?.setChapters(viewerChapters)
|
||||||
|
|
||||||
lifecycleScope.launchIO {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
viewModel.getChapterUrl()?.let { url ->
|
viewModel.getChapterUrl()?.let { url ->
|
||||||
assistUrl = url
|
assistUrl = url
|
||||||
}
|
}
|
||||||
@@ -706,7 +703,7 @@ class ReaderActivity : BaseActivity() {
|
|||||||
* the viewer is reaching the beginning or end of a chapter or the transition page is active.
|
* the viewer is reaching the beginning or end of a chapter or the transition page is active.
|
||||||
*/
|
*/
|
||||||
fun requestPreloadChapter(chapter: ReaderChapter) {
|
fun requestPreloadChapter(chapter: ReaderChapter) {
|
||||||
lifecycleScope.launchIO { viewModel.preload(chapter) }
|
lifecycleScope.launch(Dispatchers.IO) { viewModel.preload(chapter) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import eu.kanade.tachiyomi.util.storage.cacheImageDir
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -67,14 +69,12 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.metro.AppCoroutineScope
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.toggle
|
import tachiyomi.core.common.preference.toggle
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||||
@@ -191,25 +191,32 @@ class ReaderViewModel(
|
|||||||
*/
|
*/
|
||||||
private var chapterReadStartTime: Instant? = null
|
private var chapterReadStartTime: Instant? = null
|
||||||
|
|
||||||
|
// Serializes the progress writes, which outlive the reader, so they are applied in order
|
||||||
|
private val progressWrites = Mutex()
|
||||||
|
|
||||||
private var chapterToDownload: Download? = null
|
private var chapterToDownload: Download? = null
|
||||||
|
|
||||||
private val unfilteredChapterList by lazy {
|
private var unfilteredChapterList: List<Chapter>? = null
|
||||||
val manga = manga!!
|
|
||||||
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) }
|
private suspend fun getUnfilteredChapterList(): List<Chapter> {
|
||||||
|
return unfilteredChapterList
|
||||||
|
?: getChaptersByMangaId.await(manga!!.id, applyScanlatorFilter = false)
|
||||||
|
.also { unfilteredChapterList = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
|
* Chapter list for the active manga. Loaded by [init].
|
||||||
* time in a background thread to avoid blocking the UI.
|
|
||||||
*/
|
*/
|
||||||
private val chapterList by lazy {
|
private lateinit var chapterList: List<ReaderChapter>
|
||||||
|
|
||||||
|
private suspend fun loadChapterList(): List<ReaderChapter> {
|
||||||
val manga = manga!!
|
val manga = manga!!
|
||||||
val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) }
|
val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true)
|
||||||
.let { filtered ->
|
.let { filtered ->
|
||||||
if (filtered.any { it.id == chapterId }) {
|
if (filtered.any { it.id == chapterId }) {
|
||||||
filtered
|
filtered
|
||||||
} else {
|
} else {
|
||||||
filtered + unfilteredChapterList.filter { it.id == chapterId }
|
filtered + getUnfilteredChapterList().filter { it.id == chapterId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +267,7 @@ class ReaderViewModel(
|
|||||||
else -> chapters
|
else -> chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
chaptersForReader
|
return chaptersForReader
|
||||||
.sortedWith(getChapterSort(manga, sortDescending = false))
|
.sortedWith(getChapterSort(manga, sortDescending = false))
|
||||||
.run {
|
.run {
|
||||||
if (readerPreferences.skipDupe.get()) {
|
if (readerPreferences.skipDupe.get()) {
|
||||||
@@ -308,7 +315,8 @@ class ReaderViewModel(
|
|||||||
if (currentChapters != null) {
|
if (currentChapters != null) {
|
||||||
currentChapters.unref()
|
currentChapters.unref()
|
||||||
chapterToDownload?.let {
|
chapterToDownload?.let {
|
||||||
downloadManager.addDownloadsToStartOfQueue(listOf(it))
|
// viewModelScope is already cancelled here
|
||||||
|
appScope.launch { downloadManager.addDownloadsToStartOfQueue(listOf(it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +335,7 @@ class ReaderViewModel(
|
|||||||
* Failures are reported through [State.initError].
|
* Failures are reported through [State.initError].
|
||||||
*/
|
*/
|
||||||
private suspend fun init() {
|
private suspend fun init() {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found")
|
val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found")
|
||||||
val source = sourceManager.getOrStub(manga.source)
|
val source = sourceManager.getOrStub(manga.source)
|
||||||
@@ -345,6 +353,7 @@ class ReaderViewModel(
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
chapterList = loadChapterList()
|
||||||
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
|
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (e is CancellationException) {
|
if (e is CancellationException) {
|
||||||
@@ -372,13 +381,14 @@ class ReaderViewModel(
|
|||||||
chapterList.getOrNull(chapterPos + 1),
|
chapterList.getOrNull(chapterPos + 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
|
val toDownload = cancelQueuedDownloads(newChapters.currChapter)
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
// Add new references first to avoid unnecessary recycling
|
// Add new references first to avoid unnecessary recycling
|
||||||
newChapters.ref()
|
newChapters.ref()
|
||||||
it.viewerChapters?.unref()
|
it.viewerChapters?.unref()
|
||||||
|
|
||||||
chapterToDownload = cancelQueuedDownloads(newChapters.currChapter)
|
chapterToDownload = toDownload
|
||||||
it.copy(
|
it.copy(
|
||||||
viewerChapters = newChapters,
|
viewerChapters = newChapters,
|
||||||
bookmarked = newChapters.currChapter.chapter.bookmark,
|
bookmarked = newChapters.currChapter.chapter.bookmark,
|
||||||
@@ -395,7 +405,7 @@ class ReaderViewModel(
|
|||||||
private fun loadNewChapter(chapter: ReaderChapter) {
|
private fun loadNewChapter(chapter: ReaderChapter) {
|
||||||
val loader = loader ?: return
|
val loader = loader ?: return
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
logcat { "Loading ${chapter.chapter.url}" }
|
logcat { "Loading ${chapter.chapter.url}" }
|
||||||
|
|
||||||
updateHistory()
|
updateHistory()
|
||||||
@@ -422,7 +432,7 @@ class ReaderViewModel(
|
|||||||
|
|
||||||
mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
|
mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
|
||||||
try {
|
try {
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
loadChapter(loader, chapter)
|
loadChapter(loader, chapter)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -497,8 +507,8 @@ class ReaderViewModel(
|
|||||||
val selectedChapter = page.chapter
|
val selectedChapter = page.chapter
|
||||||
val pages = selectedChapter.pages ?: return
|
val pages = selectedChapter.pages ?: return
|
||||||
|
|
||||||
// Save last page read and mark as read if needed
|
// Save last page read and mark as read if needed. The database calls are main-safe.
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch(Dispatchers.Main.immediate) {
|
||||||
updateChapterProgress(selectedChapter, page)
|
updateChapterProgress(selectedChapter, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +533,7 @@ class ReaderViewModel(
|
|||||||
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
|
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
|
||||||
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
|
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
|
||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
|
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
|
||||||
nextChapter.name,
|
nextChapter.name,
|
||||||
nextChapter.scanlator,
|
nextChapter.scanlator,
|
||||||
@@ -531,7 +541,7 @@ class ReaderViewModel(
|
|||||||
manga.title,
|
manga.title,
|
||||||
manga.source,
|
manga.source,
|
||||||
)
|
)
|
||||||
if (!isNextChapterDownloaded) return@launchIO
|
if (!isNextChapterDownloaded) return@launch
|
||||||
|
|
||||||
val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id).run {
|
val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id).run {
|
||||||
if (readerPreferences.skipDupe.get()) {
|
if (readerPreferences.skipDupe.get()) {
|
||||||
@@ -552,7 +562,7 @@ class ReaderViewModel(
|
|||||||
* Removes [currentChapter] from download queue
|
* Removes [currentChapter] from download queue
|
||||||
* if setting is enabled and [currentChapter] is queued for download
|
* if setting is enabled and [currentChapter] is queued for download
|
||||||
*/
|
*/
|
||||||
private fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? {
|
private suspend fun cancelQueuedDownloads(currentChapter: ReaderChapter): Download? {
|
||||||
return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id)?.also {
|
return downloadManager.getQueuedDownloadOrNull(currentChapter.chapter.id)?.also {
|
||||||
downloadManager.cancelQueuedDownloads(listOf(it))
|
downloadManager.cancelQueuedDownloads(listOf(it))
|
||||||
}
|
}
|
||||||
@@ -563,7 +573,7 @@ class ReaderViewModel(
|
|||||||
* If both conditions are satisfied enqueues chapter for delete
|
* If both conditions are satisfied enqueues chapter for delete
|
||||||
* @param currentChapter current chapter, which is going to be marked as read.
|
* @param currentChapter current chapter, which is going to be marked as read.
|
||||||
*/
|
*/
|
||||||
private fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
|
private suspend fun deleteChapterIfNeeded(currentChapter: ReaderChapter) {
|
||||||
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots.get()
|
val removeAfterReadSlots = downloadPreferences.removeAfterReadSlots.get()
|
||||||
if (removeAfterReadSlots == -1) return
|
if (removeAfterReadSlots == -1) return
|
||||||
|
|
||||||
@@ -579,7 +589,7 @@ class ReaderViewModel(
|
|||||||
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead.get()
|
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead.get()
|
||||||
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
||||||
val duplicateChapters = if (markDuplicateAsRead) {
|
val duplicateChapters = if (markDuplicateAsRead) {
|
||||||
unfilteredChapterList
|
getUnfilteredChapterList()
|
||||||
.filter {
|
.filter {
|
||||||
it.id != chapterToDelete.chapter.id &&
|
it.id != chapterToDelete.chapter.id &&
|
||||||
it.isRecognizedNumber &&
|
it.isRecognizedNumber &&
|
||||||
@@ -609,8 +619,12 @@ class ReaderViewModel(
|
|||||||
if (!incognitoMode && page.status !is Page.State.Error) {
|
if (!incognitoMode && page.status !is Page.State.Error) {
|
||||||
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex
|
||||||
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) }
|
||||||
|
val isLastPage = readerChapter.pages?.lastIndex == pageIndex
|
||||||
|
|
||||||
if (readerChapter.pages?.lastIndex == pageIndex) {
|
// Taken before the first suspension, and the mutex is fair, so the writes land in the order the pages
|
||||||
|
// were selected even when one of them suspends longer
|
||||||
|
progressWrites.withLock {
|
||||||
|
if (isLastPage) {
|
||||||
updateChapterProgressOnComplete(readerChapter)
|
updateChapterProgressOnComplete(readerChapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +636,7 @@ class ReaderViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun updateChapterProgressOnComplete(readerChapter: ReaderChapter) {
|
private suspend fun updateChapterProgressOnComplete(readerChapter: ReaderChapter) {
|
||||||
updateTrackChapterRead(readerChapter)
|
updateTrackChapterRead(readerChapter)
|
||||||
@@ -631,7 +646,7 @@ class ReaderViewModel(
|
|||||||
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
||||||
if (!markDuplicateAsRead) return
|
if (!markDuplicateAsRead) return
|
||||||
|
|
||||||
val duplicateUnreadChapters = unfilteredChapterList
|
val duplicateUnreadChapters = getUnfilteredChapterList()
|
||||||
.mapNotNull { chapter ->
|
.mapNotNull { chapter ->
|
||||||
if (
|
if (
|
||||||
!chapter.read &&
|
!chapter.read &&
|
||||||
@@ -650,10 +665,18 @@ class ReaderViewModel(
|
|||||||
chapterReadStartTime = Clock.System.now()
|
chapterReadStartTime = Clock.System.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the chapter last read history on the app scope, so it's written even when the reader is closing.
|
||||||
|
*/
|
||||||
|
fun saveHistory() {
|
||||||
|
// Immediate so the read time is taken when the reader pauses
|
||||||
|
appScope.launch(Dispatchers.Main.immediate) { updateHistory() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the chapter last read history if incognito mode isn't on.
|
* Saves the chapter last read history if incognito mode isn't on.
|
||||||
*/
|
*/
|
||||||
suspend fun updateHistory() {
|
private suspend fun updateHistory() {
|
||||||
getCurrentChapter()?.let { readerChapter ->
|
getCurrentChapter()?.let { readerChapter ->
|
||||||
if (incognitoMode) return@let
|
if (incognitoMode) return@let
|
||||||
|
|
||||||
@@ -711,7 +734,8 @@ class ReaderViewModel(
|
|||||||
val bookmarked = !readerChapter.chapter.bookmark
|
val bookmarked = !readerChapter.chapter.bookmark
|
||||||
readerChapter.update { it.copy(bookmark = bookmarked) }
|
readerChapter.update { it.copy(bookmark = bookmarked) }
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
// On Main so quick toggles are saved in order
|
||||||
|
viewModelScope.launch {
|
||||||
updateChapter.await(
|
updateChapter.await(
|
||||||
ChapterUpdate(readerChapter.chapter.id) {
|
ChapterUpdate(readerChapter.chapter.id) {
|
||||||
bookmark = bookmarked
|
bookmark = bookmarked
|
||||||
@@ -738,25 +762,25 @@ class ReaderViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var readingModeJob: Job? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the viewer position for the open manga.
|
* Updates the viewer position for the open manga.
|
||||||
*/
|
*/
|
||||||
fun setMangaReadingMode(readingMode: ReadingMode) {
|
fun setMangaReadingMode(readingMode: ReadingMode) {
|
||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
runBlocking(Dispatchers.IO) {
|
// Only the latest pick matters, and letting an older one finish last would restore its choice
|
||||||
|
readingModeJob?.cancel()
|
||||||
|
readingModeJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
|
setMangaViewerFlags.awaitSetReadingMode(manga.id, readingMode.flagValue.toLong())
|
||||||
|
val updatedManga = getManga.await(manga.id)
|
||||||
val currChapters = state.value.viewerChapters
|
val currChapters = state.value.viewerChapters
|
||||||
if (currChapters != null) {
|
if (currChapters != null) {
|
||||||
// Save current page
|
// Save current page
|
||||||
val currChapter = currChapters.currChapter
|
val currChapter = currChapters.currChapter
|
||||||
currChapter.requestedPage = currChapter.chapter.lastPageRead
|
currChapter.requestedPage = currChapter.chapter.lastPageRead
|
||||||
|
|
||||||
mutableState.update {
|
mutableState.update { it.copy(manga = updatedManga) }
|
||||||
it.copy(
|
|
||||||
manga = getManga.await(manga.id),
|
|
||||||
viewerChapters = currChapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
eventChannel.send(Event.ReloadViewerChapters)
|
eventChannel.send(Event.ReloadViewerChapters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,25 +798,25 @@ class ReaderViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var orientationJob: Job? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the orientation type for the open manga.
|
* Updates the orientation type for the open manga.
|
||||||
*/
|
*/
|
||||||
fun setMangaOrientationType(orientation: ReaderOrientation) {
|
fun setMangaOrientationType(orientation: ReaderOrientation) {
|
||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
viewModelScope.launchIO {
|
// Only the latest pick matters, and letting an older one finish last would restore its choice
|
||||||
|
orientationJob?.cancel()
|
||||||
|
orientationJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
setMangaViewerFlags.awaitSetOrientation(manga.id, orientation.flagValue.toLong())
|
setMangaViewerFlags.awaitSetOrientation(manga.id, orientation.flagValue.toLong())
|
||||||
|
val updatedManga = getManga.await(manga.id)
|
||||||
val currChapters = state.value.viewerChapters
|
val currChapters = state.value.viewerChapters
|
||||||
if (currChapters != null) {
|
if (currChapters != null) {
|
||||||
// Save current page
|
// Save current page
|
||||||
val currChapter = currChapters.currChapter
|
val currChapter = currChapters.currChapter
|
||||||
currChapter.requestedPage = currChapter.chapter.lastPageRead
|
currChapter.requestedPage = currChapter.chapter.lastPageRead
|
||||||
|
|
||||||
mutableState.update {
|
mutableState.update { it.copy(manga = updatedManga) }
|
||||||
it.copy(
|
|
||||||
manga = getManga.await(manga.id),
|
|
||||||
viewerChapters = currChapters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
eventChannel.send(Event.SetOrientation(getMangaOrientation()))
|
eventChannel.send(Event.SetOrientation(getMangaOrientation()))
|
||||||
eventChannel.send(Event.ReloadViewerChapters)
|
eventChannel.send(Event.ReloadViewerChapters)
|
||||||
}
|
}
|
||||||
@@ -878,8 +902,9 @@ class ReaderViewModel(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy file in background.
|
// The save and its notification outlive the reader, the result event doesn't
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launch {
|
||||||
|
val result = appScope.async {
|
||||||
try {
|
try {
|
||||||
val uri = imageSaver.save(
|
val uri = imageSaver.save(
|
||||||
image = Image.Page(
|
image = Image.Page(
|
||||||
@@ -888,14 +913,14 @@ class ReaderViewModel(
|
|||||||
location = Location.Pictures.create(relativePath),
|
location = Location.Pictures.create(relativePath),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) { notifier.onComplete(uri) }
|
||||||
notifier.onComplete(uri)
|
SaveImageResult.Success(uri)
|
||||||
eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri)))
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
notifier.onError(e.message)
|
notifier.onError(e.message)
|
||||||
eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
|
SaveImageResult.Error(e)
|
||||||
}
|
}
|
||||||
|
}.await()
|
||||||
|
eventChannel.send(Event.SavedImage(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,8 +940,8 @@ class ReaderViewModel(
|
|||||||
|
|
||||||
val filename = generateFilename(manga, page)
|
val filename = generateFilename(manga, page)
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
viewModelScope.launchNonCancellable {
|
|
||||||
destDir.deleteRecursively()
|
destDir.deleteRecursively()
|
||||||
val uri = imageSaver.save(
|
val uri = imageSaver.save(
|
||||||
image = Image.Page(
|
image = Image.Page(
|
||||||
@@ -926,11 +951,12 @@ class ReaderViewModel(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page))
|
eventChannel.send(if (copyToClipboard) Event.CopyImage(uri) else Event.ShareImage(uri, page))
|
||||||
}
|
} catch (e: Exception) {
|
||||||
} catch (e: Throwable) {
|
if (e is CancellationException) throw e
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the image of the selected page as cover and notifies the UI of the result.
|
* Sets the image of the selected page as cover and notifies the UI of the result.
|
||||||
@@ -941,8 +967,9 @@ class ReaderViewModel(
|
|||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
val stream = page.stream ?: return
|
val stream = page.stream ?: return
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
viewModelScope.launch {
|
||||||
val result = try {
|
val result = appScope.async {
|
||||||
|
try {
|
||||||
manga.editCover(coverManager, stream(), updateManga, coverCache)
|
manga.editCover(coverManager, stream(), updateManga, coverCache)
|
||||||
if (manga.isLocal() || manga.favorite) {
|
if (manga.isLocal() || manga.favorite) {
|
||||||
SetAsCoverResult.Success
|
SetAsCoverResult.Success
|
||||||
@@ -952,6 +979,7 @@ class ReaderViewModel(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
SetAsCoverResult.Error
|
SetAsCoverResult.Error
|
||||||
}
|
}
|
||||||
|
}.await()
|
||||||
eventChannel.send(Event.SetCoverResult(result))
|
eventChannel.send(Event.SetCoverResult(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -977,7 +1005,8 @@ class ReaderViewModel(
|
|||||||
|
|
||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
// Reading the last page and leaving right away is common, so it outlives the reader
|
||||||
|
appScope.launch {
|
||||||
trackChapter.await(context, manga.id, readerChapter.chapter.chapterNumber)
|
trackChapter.await(context, manga.id, readerChapter.chapter.chapterNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -986,20 +1015,19 @@ class ReaderViewModel(
|
|||||||
* Enqueues these [chapters] to be deleted when [deletePendingChapters] is called. The download
|
* Enqueues these [chapters] to be deleted when [deletePendingChapters] is called. The download
|
||||||
* manager handles persisting them across process deaths.
|
* manager handles persisting them across process deaths.
|
||||||
*/
|
*/
|
||||||
private fun enqueueDeleteChapters(chapters: List<Chapter>) {
|
private suspend fun enqueueDeleteChapters(chapters: List<Chapter>) {
|
||||||
val manga = manga ?: return
|
val manga = manga ?: return
|
||||||
|
|
||||||
viewModelScope.launchNonCancellable {
|
|
||||||
downloadManager.enqueueChaptersToDelete(chapters, manga)
|
downloadManager.enqueueChaptersToDelete(chapters, manga)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes all the pending chapters. This operation will run in a background thread and errors
|
* Deletes all the pending chapters. This operation will run in a background thread and errors
|
||||||
* are ignored.
|
* are ignored.
|
||||||
*/
|
*/
|
||||||
private fun deletePendingChapters() {
|
private fun deletePendingChapters() {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
|
// Progress writes started before leaving may still enqueue the chapter just finished
|
||||||
|
progressWrites.withLock {}
|
||||||
downloadManager.deletePendingChapters()
|
downloadManager.deletePendingChapters()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import eu.kanade.tachiyomi.source.Source
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import mihon.core.archive.archiveReader
|
import mihon.core.archive.archiveReader
|
||||||
import mihon.core.archive.epubReader
|
import mihon.core.archive.epubReader
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.source.model.StubSource
|
import tachiyomi.domain.source.model.StubSource
|
||||||
@@ -43,7 +44,7 @@ class ChapterLoader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
chapter.state = ReaderChapter.State.Loading
|
chapter.state = ReaderChapter.State.Loading
|
||||||
withIOContext {
|
withContext(Dispatchers.IO) {
|
||||||
logcat { "Loading pages for ${chapter.chapter.name}" }
|
logcat { "Loading pages for ${chapter.chapter.name}" }
|
||||||
try {
|
try {
|
||||||
val loader = getPageLoader(chapter)
|
val loader = getPageLoader(chapter)
|
||||||
@@ -80,7 +81,7 @@ class ChapterLoader(
|
|||||||
/**
|
/**
|
||||||
* Returns the page loader to use for this [chapter].
|
* Returns the page loader to use for this [chapter].
|
||||||
*/
|
*/
|
||||||
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
|
private suspend fun getPageLoader(chapter: ReaderChapter): PageLoader {
|
||||||
val dbChapter = chapter.chapter
|
val dbChapter = chapter.chapter
|
||||||
val isDownloaded = downloadManager.isChapterDownloadedOnDisk(
|
val isDownloaded = downloadManager.isChapterDownloadedOnDisk(
|
||||||
dbChapter.name,
|
dbChapter.name,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ internal class DownloadPageLoader(
|
|||||||
return loader.getPages()
|
return loader.getPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPagesFromDirectory(): List<ReaderPage> {
|
private suspend fun getPagesFromDirectory(): List<ReaderPage> {
|
||||||
val pages = downloadManager.buildPageList(source, manga, chapter.chapter)
|
val pages = downloadManager.buildPageList(source, manga, chapter.chapter)
|
||||||
return pages.map { page ->
|
return pages.map { page ->
|
||||||
ReaderPage(page.index, page.url, page.imageUrl) {
|
ReaderPage(page.index, page.url, page.imageUrl) {
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import kotlinx.coroutines.withContext
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import java.util.concurrent.PriorityBlockingQueue
|
import java.util.concurrent.PriorityBlockingQueue
|
||||||
import kotlin.concurrent.atomics.AtomicInt
|
import kotlin.concurrent.atomics.AtomicInt
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
@@ -42,8 +44,14 @@ internal class HttpPageLoader(
|
|||||||
|
|
||||||
private val preloadSize = 4
|
private val preloadSize = 4
|
||||||
|
|
||||||
|
// How many pending loadPage calls want each page index, counting their preloads. Pages are downloaded one
|
||||||
|
// at a time, so once nothing wants the page in flight its download is cancelled instead of holding up the
|
||||||
|
// pages being read. Guards inFlight too.
|
||||||
|
private val wantedPages = HashMap<Int, Int>()
|
||||||
|
private var inFlight: Pair<Int, Job>? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launchIO {
|
scope.launch(Dispatchers.IO) {
|
||||||
flow {
|
flow {
|
||||||
while (true) {
|
while (true) {
|
||||||
emit(runInterruptible { queue.take() })
|
emit(runInterruptible { queue.take() })
|
||||||
@@ -51,11 +59,18 @@ internal class HttpPageLoader(
|
|||||||
}
|
}
|
||||||
.filter { it.page.status == Page.State.Queue }
|
.filter { it.page.status == Page.State.Queue }
|
||||||
.collect {
|
.collect {
|
||||||
|
coroutineScope {
|
||||||
|
val load = launch {
|
||||||
internalLoadPage(
|
internalLoadPage(
|
||||||
page = it.page,
|
page = it.page,
|
||||||
force = it.priority == PriorityPage.RETRY,
|
force = it.priority == PriorityPage.RETRY,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
synchronized(wantedPages) { inFlight = it.page.index to load }
|
||||||
|
load.join()
|
||||||
|
synchronized(wantedPages) { inFlight = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +104,7 @@ internal class HttpPageLoader(
|
|||||||
// Check if the image has been deleted. Only this touches the disk; the queueing below
|
// Check if the image has been deleted. Only this touches the disk; the queueing below
|
||||||
// stays on the caller's thread, so requests queue in the order they were made.
|
// stays on the caller's thread, so requests queue in the order they were made.
|
||||||
if (page.status == Page.State.Ready && imageUrl != null &&
|
if (page.status == Page.State.Ready && imageUrl != null &&
|
||||||
!withIOContext { chapterCache.isImageInCache(imageUrl) }
|
!withContext(Dispatchers.IO) { chapterCache.isImageInCache(imageUrl) }
|
||||||
) {
|
) {
|
||||||
page.status = Page.State.Queue
|
page.status = Page.State.Queue
|
||||||
}
|
}
|
||||||
@@ -99,11 +114,23 @@ internal class HttpPageLoader(
|
|||||||
page.status = Page.State.Queue
|
page.status = Page.State.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val nextPages = nextPages(page, preloadSize)
|
||||||
|
val wanted = listOf(page) + nextPages
|
||||||
|
synchronized(wantedPages) {
|
||||||
|
wanted.forEach { wantedPages.merge(it.index, 1, Int::plus) }
|
||||||
|
}
|
||||||
|
|
||||||
val queuedPages = mutableListOf<PriorityPage>()
|
val queuedPages = mutableListOf<PriorityPage>()
|
||||||
if (page.status == Page.State.Queue) {
|
if (page.status == Page.State.Queue) {
|
||||||
queuedPages += PriorityPage(page, PriorityPage.DEFAULT).also { queue.offer(it) }
|
queuedPages += PriorityPage(page, PriorityPage.DEFAULT).also { queue.offer(it) }
|
||||||
}
|
}
|
||||||
queuedPages += preloadNextPages(page, preloadSize)
|
queuedPages += nextPages.mapNotNull {
|
||||||
|
if (it.status == Page.State.Queue) {
|
||||||
|
PriorityPage(it, PriorityPage.ADJACENT).apply { queue.offer(this) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspendCancellableCoroutine<Nothing> { continuation ->
|
suspendCancellableCoroutine<Nothing> { continuation ->
|
||||||
continuation.invokeOnCancellation {
|
continuation.invokeOnCancellation {
|
||||||
@@ -112,6 +139,13 @@ internal class HttpPageLoader(
|
|||||||
queue.remove(it)
|
queue.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
synchronized(wantedPages) {
|
||||||
|
wanted.forEach {
|
||||||
|
wantedPages.computeIfPresent(it.index) { _, count -> (count - 1).takeIf { it > 0 } }
|
||||||
|
}
|
||||||
|
// A cancelled load ends in Error, which the next loadPage of that page queues again
|
||||||
|
inFlight?.let { (index, load) -> if (index !in wantedPages) load.cancel() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +167,7 @@ internal class HttpPageLoader(
|
|||||||
|
|
||||||
// Cache current page list progress for online chapters to allow a faster reopen
|
// Cache current page list progress for online chapters to allow a faster reopen
|
||||||
chapter.pages?.let { pages ->
|
chapter.pages?.let { pages ->
|
||||||
appScope.launchIO {
|
appScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Convert to pages without reader information
|
// Convert to pages without reader information
|
||||||
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
|
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
|
||||||
@@ -148,24 +182,14 @@ internal class HttpPageLoader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
|
* Returns the [amount] of pages after [currentPage], which are preloaded with a lower priority.
|
||||||
*
|
|
||||||
* @return a list of [PriorityPage] that were added to the [queue]
|
|
||||||
*/
|
*/
|
||||||
private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List<PriorityPage> {
|
private fun nextPages(currentPage: ReaderPage, amount: Int): List<ReaderPage> {
|
||||||
val pageIndex = currentPage.index
|
val pageIndex = currentPage.index
|
||||||
val pages = currentPage.chapter.pages ?: return emptyList()
|
val pages = currentPage.chapter.pages ?: return emptyList()
|
||||||
if (pageIndex == pages.lastIndex) return emptyList()
|
if (pageIndex == pages.lastIndex) return emptyList()
|
||||||
|
|
||||||
return pages
|
return pages.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
|
||||||
.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
|
|
||||||
.mapNotNull {
|
|
||||||
if (it.status == Page.State.Queue) {
|
|
||||||
PriorityPage(it, PriorityPage.ADJACENT).apply { queue.offer(this) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.AbstractComposeView
|
import androidx.compose.ui.platform.AbstractComposeView
|
||||||
import eu.kanade.presentation.reader.ChapterTransition
|
import eu.kanade.presentation.reader.ChapterTransition
|
||||||
@@ -30,19 +31,31 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
|
|||||||
|
|
||||||
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
|
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
|
||||||
data = if (manga != null && source != null) {
|
data = if (manga != null && source != null) {
|
||||||
|
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
|
||||||
Data(
|
Data(
|
||||||
transition = transition,
|
transition = transition,
|
||||||
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
|
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
|
||||||
goingToChapterDownloaded = manga.isLocal() ||
|
goingToChapterDownloaded = manga.isLocal() ||
|
||||||
transition.to?.chapter?.let { goingToChapter ->
|
goingToChapter?.let {
|
||||||
|
downloadManager.isChapterDownloaded(
|
||||||
|
chapterName = it.name,
|
||||||
|
chapterScanlator = it.scanlator,
|
||||||
|
chapterUrl = it.url,
|
||||||
|
mangaTitle = manga.title,
|
||||||
|
sourceId = manga.source,
|
||||||
|
)
|
||||||
|
} ?: false,
|
||||||
|
isGoingToChapterOnDisk = goingToChapter?.let {
|
||||||
|
{
|
||||||
downloadManager.isChapterDownloadedOnDisk(
|
downloadManager.isChapterDownloadedOnDisk(
|
||||||
chapterName = goingToChapter.name,
|
chapterName = it.name,
|
||||||
chapterScanlator = goingToChapter.scanlator,
|
chapterScanlator = it.scanlator,
|
||||||
chapterUrl = goingToChapter.url,
|
chapterUrl = it.url,
|
||||||
mangaTitle = manga.title,
|
mangaTitle = manga.title,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
} ?: false,
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -52,6 +65,9 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
|
|||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
data?.let {
|
data?.let {
|
||||||
|
val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
|
||||||
|
it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
|
||||||
|
}
|
||||||
TachiyomiTheme {
|
TachiyomiTheme {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
LocalTextStyle provides MaterialTheme.typography.bodySmall,
|
||||||
@@ -60,7 +76,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
|
|||||||
ChapterTransition(
|
ChapterTransition(
|
||||||
transition = it.transition,
|
transition = it.transition,
|
||||||
currChapterDownloaded = it.currChapterDownloaded,
|
currChapterDownloaded = it.currChapterDownloaded,
|
||||||
goingToChapterDownloaded = it.goingToChapterDownloaded,
|
goingToChapterDownloaded = goingToChapterDownloaded,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,5 +87,6 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
|
|||||||
val transition: ChapterTransition,
|
val transition: ChapterTransition,
|
||||||
val currChapterDownloaded: Boolean,
|
val currChapterDownloaded: Boolean,
|
||||||
val goingToChapterDownloaded: Boolean,
|
val goingToChapterDownloaded: Boolean,
|
||||||
|
val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,17 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
|||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||||
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.ImageUtil
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -94,7 +93,7 @@ class PagerPageHolder(
|
|||||||
val loader = page.chapter.pageLoader ?: return
|
val loader = page.chapter.pageLoader ?: return
|
||||||
|
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
launchIO {
|
launch(Dispatchers.IO) {
|
||||||
loader.loadPage(page)
|
loader.loadPage(page)
|
||||||
}
|
}
|
||||||
page.statusFlow.collectLatest { state ->
|
page.statusFlow.collectLatest { state ->
|
||||||
@@ -150,8 +149,10 @@ class PagerPageHolder(
|
|||||||
val streamFn = page.stream ?: return
|
val streamFn = page.stream ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val (source, isAnimated, background) = withIOContext {
|
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
|
||||||
val source = streamFn().use { process(item, Buffer().readFrom(it)) }
|
// Splitting, merging and sampling the background decode the image, which is CPU work
|
||||||
|
val (source, isAnimated, background) = withContext(Dispatchers.Default) {
|
||||||
|
val source = process(item, buffer)
|
||||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||||
ImageUtil.chooseBackground(context, source.peek().inputStream())
|
ImageUtil.chooseBackground(context, source.peek().inputStream())
|
||||||
@@ -160,7 +161,7 @@ class PagerPageHolder(
|
|||||||
}
|
}
|
||||||
Triple(source, isAnimated, background)
|
Triple(source, isAnimated, background)
|
||||||
}
|
}
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
setImage(
|
setImage(
|
||||||
source,
|
source,
|
||||||
isAnimated,
|
isAnimated,
|
||||||
@@ -179,7 +180,7 @@ class PagerPageHolder(
|
|||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
setError(e)
|
setError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ open class WebGpuViewer(
|
|||||||
if (!chapterPreloadsInFlight.add(chapterId)) return
|
if (!chapterPreloadsInFlight.add(chapterId)) return
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
activity.viewModel.preload(chapter)
|
activity.viewModel.preload(chapter)
|
||||||
repeat(25) {
|
repeat(25) {
|
||||||
|
|||||||
@@ -18,18 +18,17 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
|||||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
|
||||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||||
import eu.kanade.tachiyomi.util.system.dpToPx
|
import eu.kanade.tachiyomi.util.system.dpToPx
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
|
||||||
import tachiyomi.core.common.util.lang.withUIContext
|
|
||||||
import tachiyomi.core.common.util.system.ImageUtil
|
import tachiyomi.core.common.util.system.ImageUtil
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
@@ -134,7 +133,7 @@ class WebtoonPageHolder(
|
|||||||
val page = page ?: return
|
val page = page ?: return
|
||||||
val loader = page.chapter.pageLoader ?: return
|
val loader = page.chapter.pageLoader ?: return
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
launchIO {
|
launch(Dispatchers.IO) {
|
||||||
loader.loadPage(page)
|
loader.loadPage(page)
|
||||||
}
|
}
|
||||||
page.statusFlow.collectLatest { state ->
|
page.statusFlow.collectLatest { state ->
|
||||||
@@ -190,12 +189,14 @@ class WebtoonPageHolder(
|
|||||||
val streamFn = page?.stream ?: return
|
val streamFn = page?.stream ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val (source, isAnimated) = withIOContext {
|
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
|
||||||
val source = streamFn().use { process(Buffer().readFrom(it)) }
|
// Splitting and merging decode the image, which is CPU work
|
||||||
|
val (source, isAnimated) = withContext(Dispatchers.Default) {
|
||||||
|
val source = process(buffer)
|
||||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||||
Pair(source, isAnimated)
|
Pair(source, isAnimated)
|
||||||
}
|
}
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
frame.setImage(
|
frame.setImage(
|
||||||
source,
|
source,
|
||||||
isAnimated,
|
isAnimated,
|
||||||
@@ -209,7 +210,7 @@ class WebtoonPageHolder(
|
|||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
withUIContext {
|
withContext(Dispatchers.Main) {
|
||||||
setError(e)
|
setError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import eu.kanade.presentation.more.stats.data.StatsData
|
|||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
import tachiyomi.domain.history.interactor.GetTotalReadDuration
|
||||||
import tachiyomi.domain.library.model.LibraryManga
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.library.service.LibraryPreferences
|
import tachiyomi.domain.library.service.LibraryPreferences
|
||||||
@@ -48,7 +49,7 @@ class StatsViewModel(
|
|||||||
private val loggedInTrackers by lazy { trackerManager.loggedInTrackers() }
|
private val loggedInTrackers by lazy { trackerManager.loggedInTrackers() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val libraryManga = getLibraryManga.await()
|
val libraryManga = getLibraryManga.await()
|
||||||
|
|
||||||
val distinctLibraryManga = libraryManga.fastDistinctBy { it.id }
|
val distinctLibraryManga = libraryManga.fastDistinctBy { it.id }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
|
|||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateWorker
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateWorker
|
||||||
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
import eu.kanade.tachiyomi.util.lang.toLocalDate
|
||||||
import eu.kanade.tachiyomi.util.system.workManager
|
import eu.kanade.tachiyomi.util.system.workManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -46,9 +47,8 @@ import kotlinx.datetime.DateTimeUnit
|
|||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import mihon.core.metro.AppCoroutineScope
|
||||||
import tachiyomi.core.common.preference.TriState
|
import tachiyomi.core.common.preference.TriState
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
|
||||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import tachiyomi.domain.chapter.interactor.GetChapter
|
import tachiyomi.domain.chapter.interactor.GetChapter
|
||||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||||
@@ -67,6 +67,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@ViewModelKey
|
@ViewModelKey
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||||
class UpdatesViewModel(
|
class UpdatesViewModel(
|
||||||
|
@AppCoroutineScope private val appScope: CoroutineScope,
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val sourceManager: SourceManager,
|
private val sourceManager: SourceManager,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
@@ -103,7 +104,7 @@ class UpdatesViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
merge(downloadManager.statusFlow(), downloadManager.progressFlow())
|
||||||
.catch { logcat(LogPriority.ERROR, it) }
|
.catch { logcat(LogPriority.ERROR, it) }
|
||||||
.collect(this@UpdatesViewModel::updateDownloadState)
|
.collect(this@UpdatesViewModel::updateDownloadState)
|
||||||
@@ -167,7 +168,7 @@ class UpdatesViewModel(
|
|||||||
.toUpdateItems()
|
.toUpdateItems()
|
||||||
.applyFilters(itemPreferences)
|
.applyFilters(itemPreferences)
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null)
|
||||||
|
|
||||||
val state: StateFlow<State> = combine(
|
val state: StateFlow<State> = combine(
|
||||||
@@ -199,6 +200,7 @@ class UpdatesViewModel(
|
|||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State())
|
||||||
|
|
||||||
private fun List<UpdatesItem>.applyFilters(
|
private fun List<UpdatesItem>.applyFilters(
|
||||||
@@ -242,11 +244,9 @@ class UpdatesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibrary(): Boolean {
|
suspend fun updateLibrary(): Boolean {
|
||||||
val started = LibraryUpdateWorker.startNow(context.workManager)
|
val started = LibraryUpdateWorker.startNow(context.workManager)
|
||||||
viewModelScope.launch {
|
|
||||||
_events.send(Event.LibraryUpdateTriggered(started))
|
_events.send(Event.LibraryUpdateTriggered(started))
|
||||||
}
|
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +276,11 @@ class UpdatesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadingNow(chapterId: Long) {
|
private suspend fun startDownloadingNow(chapterId: Long) {
|
||||||
downloadManager.startDownloadNow(chapterId)
|
downloadManager.startDownloadNow(chapterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelDownload(chapterId: Long) {
|
private suspend fun cancelDownload(chapterId: Long) {
|
||||||
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
|
val activeDownload = downloadManager.getQueuedDownloadOrNull(chapterId) ?: return
|
||||||
downloadManager.cancelQueuedDownloads(listOf(activeDownload))
|
downloadManager.cancelQueuedDownloads(listOf(activeDownload))
|
||||||
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
|
||||||
@@ -292,7 +292,7 @@ class UpdatesViewModel(
|
|||||||
* @param read whether to mark chapters as read or unread.
|
* @param read whether to mark chapters as read or unread.
|
||||||
*/
|
*/
|
||||||
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
|
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
|
||||||
viewModelScope.launchIO {
|
appScope.launch {
|
||||||
setReadStatus.await(
|
setReadStatus.await(
|
||||||
read = read,
|
read = read,
|
||||||
chapters = updates
|
chapters = updates
|
||||||
@@ -308,7 +308,7 @@ class UpdatesViewModel(
|
|||||||
* @param updates the list of chapters to bookmark.
|
* @param updates the list of chapters to bookmark.
|
||||||
*/
|
*/
|
||||||
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
|
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launch {
|
||||||
updates
|
updates
|
||||||
.filterNot { it.update.bookmark == bookmark }
|
.filterNot { it.update.bookmark == bookmark }
|
||||||
.map { ChapterUpdate(it.update.chapterId) { this.bookmark = bookmark } }
|
.map { ChapterUpdate(it.update.chapterId) { this.bookmark = bookmark } }
|
||||||
@@ -322,7 +322,7 @@ class UpdatesViewModel(
|
|||||||
* @param updatesItem the list of chapters to download.
|
* @param updatesItem the list of chapters to download.
|
||||||
*/
|
*/
|
||||||
private fun downloadChapters(updatesItem: List<UpdatesItem>) {
|
private fun downloadChapters(updatesItem: List<UpdatesItem>) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
|
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
|
||||||
for (updates in groupedUpdates) {
|
for (updates in groupedUpdates) {
|
||||||
val mangaId = updates.first().update.mangaId
|
val mangaId = updates.first().update.mangaId
|
||||||
@@ -341,7 +341,7 @@ class UpdatesViewModel(
|
|||||||
* @param updatesItem list of chapters
|
* @param updatesItem list of chapters
|
||||||
*/
|
*/
|
||||||
fun deleteChapters(updatesItem: List<UpdatesItem>) {
|
fun deleteChapters(updatesItem: List<UpdatesItem>) {
|
||||||
viewModelScope.launchNonCancellable {
|
appScope.launch {
|
||||||
updatesItem
|
updatesItem
|
||||||
.groupBy { it.update.mangaId }
|
.groupBy { it.update.mangaId }
|
||||||
.entries
|
.entries
|
||||||
@@ -372,8 +372,8 @@ class UpdatesViewModel(
|
|||||||
val currentSelection = selectedChapterIds.value
|
val currentSelection = selectedChapterIds.value
|
||||||
if ((item.update.chapterId in currentSelection) == selected) return
|
if ((item.update.chapterId in currentSelection) == selected) return
|
||||||
|
|
||||||
// Off the visible items, not the id set, which can retain ids filtered out of the list
|
// Only visible items count, since the id set can retain ids filtered out of the list
|
||||||
val firstSelection = items.none { it.selected }
|
val firstSelection = items.none { it.update.chapterId in currentSelection }
|
||||||
val newSelection = currentSelection.toHashSet()
|
val newSelection = currentSelection.toHashSet()
|
||||||
newSelection.addOrRemove(item.update.chapterId, selected)
|
newSelection.addOrRemove(item.update.chapterId, selected)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import eu.kanade.tachiyomi.util.system.openInBrowser
|
|||||||
import eu.kanade.tachiyomi.util.system.toShareIntent
|
import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.util.view.setComposeContent
|
import eu.kanade.tachiyomi.util.view.setComposeContent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
@@ -67,13 +69,15 @@ class WebViewActivity : BaseActivity() {
|
|||||||
// Null until the source it belongs to has been resolved
|
// Null until the source it belongs to has been resolved
|
||||||
val headers by produceState<Map<String, String>?>(initialValue = null) {
|
val headers by produceState<Map<String, String>?>(initialValue = null) {
|
||||||
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
|
val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource
|
||||||
value = try {
|
value = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
|
source?.headers?.toMultimap()?.mapValues { it.value.getOrNull(0) ?: "" }.orEmpty()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
logcat(LogPriority.ERROR, e) { "Failed to build headers" }
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (headers == null) {
|
if (headers == null) {
|
||||||
LoadingScreen()
|
LoadingScreen()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user