From 260e31cd2c777ea29f04501825e65b9e6aa236cf Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:28:52 +0600 Subject: [PATCH] Improve performance across screens (#4071) * Keep screens loaded while they're hidden and rebuild the library faster * Count chapters in SQL for the migration list * Show the extension list sooner and check trust against loaded stores * Cancel the previous tracker search when searching again * Write reader progress less often and keep its work off the main thread * Stop rebuilding the settings search index on every key * Watch updates for widgets only while one is placed The query re-ran on every chapter or manga write even with no widget on the home screen. * Observe download pages and limit how often their progress updates * Throttle the library update notification and write fetch data together --- .../interactor/SyncChaptersWithSource.kt | 3 +- .../interactor/GetExtensionsByType.kt | 5 +- .../extension/interactor/TrustExtension.kt | 5 +- .../domain/manga/interactor/UpdateManga.kt | 16 ++- .../settings/screen/SettingsSearchScreen.kt | 109 ++++++++++-------- .../tachiyomi/data/download/DownloadCache.kt | 21 ++++ .../data/download/DownloadManager.kt | 9 ++ .../data/download/DownloadNotifier.kt | 15 ++- .../tachiyomi/data/download/model/Download.kt | 11 +- .../data/library/LibraryUpdateNotifier.kt | 12 ++ .../extension/util/ExtensionLoader.kt | 2 +- .../browse/extension/ExtensionsViewModel.kt | 4 +- .../ui/download/DownloadQueueViewModel.kt | 10 +- .../tachiyomi/ui/history/HistoryViewModel.kt | 9 +- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 3 + .../tachiyomi/ui/library/LibraryViewModel.kt | 22 ++-- .../tachiyomi/ui/manga/MangaViewModel.kt | 22 +++- .../ui/manga/track/TrackInfoDialog.kt | 8 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 72 +++++++++--- .../tachiyomi/ui/updates/UpdatesViewModel.kt | 21 ++-- .../migration/list/MigrationListViewModel.kt | 10 +- .../feature/upcoming/UpcomingViewModel.kt | 11 +- .../core/common/util/lang/SubscriberGate.kt | 60 ++++++++++ .../core/common/util/system/ImageUtil.kt | 10 +- .../java/tachiyomi/data/QueryExtension.kt | 7 +- .../data/chapter/ChapterRepositoryImpl.kt | 9 ++ .../main/sqldelight/tachiyomi/data/chapter.sq | 5 + .../interactor/GetChapterSummaryByMangaId.kt | 15 +++ .../domain/chapter/model/ChapterSummary.kt | 6 + .../chapter/repository/ChapterRepository.kt | 3 + .../UpdatesGridCoverScreenGlanceReceiver.kt | 11 ++ .../widget/UpdatesGridGlanceReceiver.kt | 11 ++ .../presentation/widget/WidgetManager.kt | 57 +++++++-- 33 files changed, 458 insertions(+), 136 deletions(-) create mode 100644 core/common/src/main/kotlin/tachiyomi/core/common/util/lang/SubscriberGate.kt create mode 100644 domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChapterSummaryByMangaId.kt create mode 100644 domain/src/main/java/tachiyomi/domain/chapter/model/ChapterSummary.kt diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index 2c29786c9..eb073ffa6 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -225,11 +225,10 @@ class SyncChaptersWithSource( added = toAdd, updated = updatedChapters, ) - updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow) // Set this manga as updated since chapters were changed // Note that last_update actually represents last time the chapter list changed at all - updateManga.awaitUpdateLastUpdate(manga.id) + updateManga.awaitUpdateFetchIntervalAndLastUpdate(manga, timeZone, now, fetchWindow) val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet() diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt index 88260a876..35d0df764 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt @@ -30,11 +30,10 @@ class GetExtensionsByType( val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName) + val installedSignatures = (_loaded + _notLoaded).groupBy({ it.pkgName }, { it.signatures }) val available = _available .filter { extension -> - (_loaded + _notLoaded).none { - it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures - } && + installedSignatures[extension.pkgName].orEmpty().none { extension.store.signingKey in it } && extension.contentWarning in enabledContentWarnings } .flatMap { ext -> diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt index f3094b57b..decf56c16 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.repository.ExtensionStoreRepository import tachiyomi.core.common.preference.getAndSet @@ -18,8 +19,8 @@ class TrustExtension( private val preferences: SourcePreferences, ) { - suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List): Boolean { - val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet() + fun isTrusted(pkgInfo: PackageInfo, fingerprints: List, stores: List): Boolean { + val trustedFingerprints = stores.mapTo(HashSet()) { it.signingKey } val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}" return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get() } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt index c114ae3ce..7f9c2e4ab 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/UpdateManga.kt @@ -36,8 +36,20 @@ class UpdateManga( ) } - suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean { - return mangaRepository.update(MangaUpdate(mangaId) { lastUpdate = Clock.System.now() }) + /** + * Updates the fetch interval and marks the chapter list as changed in a single write. + */ + suspend fun awaitUpdateFetchIntervalAndLastUpdate( + manga: Manga, + timeZone: TimeZone, + dateTime: LocalDateTime, + window: ClosedRange?, + ): Boolean { + return mangaRepository.update( + fetchInterval.withFetchInterval(manga, dateTime, timeZone, window).apply { + lastUpdate = Clock.System.now() + }, + ) } suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt index 8c21f5aed..51f320808 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -48,6 +49,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.components.UpIcon import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.util.Screen +import kotlinx.coroutines.flow.collectLatest import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Close import tachiyomi.i18n.MR @@ -141,7 +143,7 @@ class SettingsSearchScreen : Screen() { }, ) { contentPadding -> SearchResult( - searchKey = textFieldState.text.toString(), + searchKey = { textFieldState.text.toString() }, listState = listState, contentPadding = contentPadding, ) { result -> @@ -154,64 +156,20 @@ class SettingsSearchScreen : Screen() { @Composable private fun SearchResult( - searchKey: String, + searchKey: () -> String, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(), onItemClick: (SearchResultItem) -> Unit, ) { - if (searchKey.isEmpty()) return - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val index = getIndex() - val result by produceState?>(initialValue = null, searchKey) { - value = index.asSequence() - .flatMap { settingsData -> - fun result(title: String, categoryTitle: String?) = SearchResultItem( - route = settingsData.route, - title = title, - breadcrumbs = getLocalizedBreadcrumb( - path = settingsData.title, - node = categoryTitle, - isLtr = isLtr, - ), - highlightKey = title, - ) - - fun Preference.PreferenceItem<*, *>.matches(): Boolean { - // Don't show info preference - if (this is Preference.PreferenceItem.InfoPreference) return false - val inTitle = title.contains(searchKey, true) - val inSummary = subtitle?.contains(searchKey, true) ?: false - return inTitle || inSummary - } - - settingsData.contents.asSequence() - // Only search from enabled prefs and one with valid title - .filter { it.visible && it.title.isNotBlank() } - .flatMap { p -> - when (p) { - is Preference.PreferenceGroup -> { - // A matching heading leads to the group itself, not to one of its items - val heading = if (p.title.contains(searchKey, true)) { - sequenceOf(result(p.title, null)) - } else { - emptySequence() - } - val items = p.preferenceItems.asSequence() - .filter { it.visible && it.title.isNotBlank() && it.matches() } - .map { result(it.title, p.title) } - heading + items - } - is Preference.PreferenceItem<*, *> -> { - if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence() - } - } - } - } - .take(10) // Just take top 10 result for quicker result - .toList() + // The query is read here rather than in composition, so typing doesn't rebuild the index on every key + val result by produceState?>(initialValue = null, index) { + snapshotFlow(searchKey).collectLatest { searchKey -> + value = if (searchKey.isEmpty()) null else search(index, searchKey, isLtr) + } } Crossfade( @@ -262,6 +220,55 @@ private fun SearchResult( } } +private fun search(index: List, searchKey: String, isLtr: Boolean): List { + return index.asSequence() + .flatMap { settingsData -> + fun result(title: String, categoryTitle: String?) = SearchResultItem( + route = settingsData.route, + title = title, + breadcrumbs = getLocalizedBreadcrumb( + path = settingsData.title, + node = categoryTitle, + isLtr = isLtr, + ), + highlightKey = title, + ) + + fun Preference.PreferenceItem<*, *>.matches(): Boolean { + // Don't show info preference + if (this is Preference.PreferenceItem.InfoPreference) return false + val inTitle = title.contains(searchKey, true) + val inSummary = subtitle?.contains(searchKey, true) ?: false + return inTitle || inSummary + } + + settingsData.contents.asSequence() + // Only search from enabled prefs and one with valid title + .filter { it.visible && it.title.isNotBlank() } + .flatMap { p -> + when (p) { + is Preference.PreferenceGroup -> { + // A matching heading leads to the group itself, not to one of its items + val heading = if (p.title.contains(searchKey, true)) { + sequenceOf(result(p.title, null)) + } else { + emptySequence() + } + val items = p.preferenceItems.asSequence() + .filter { it.visible && it.title.isNotBlank() && it.matches() } + .map { result(it.title, p.title) } + heading + items + } + is Preference.PreferenceItem<*, *> -> { + if (p.matches()) sequenceOf(result(p.title, null)) else emptySequence() + } + } + } + } + .take(10) // Just take top 10 result for quicker result + .toList() +} + @Composable @NonRestartableComposable private fun getIndex() = settingScreens diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index ebb2804c8..18fe4dc43 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -211,6 +211,27 @@ class DownloadCache( return 0 } + /** + * Returns the amount of downloaded chapters for each of the given manga, keyed by manga id. + * + * @param manga the manga to check. + */ + fun getDownloadCounts(manga: List): Map { + renewCache() + + val sourceDirs = rootDownloadsDir.sourceDirs + return manga.associate { + val mangaDirs = sourceDirs[it.source]?.mangaDirs + // Building the directory name is the costly part, so it's skipped for sources without downloads + val mangaDir = if (mangaDirs.isNullOrEmpty()) { + null + } else { + mangaDirs[provider.getMangaDirName(it.title)] + } + it.id to (mangaDir?.chapterDirs?.size ?: 0) + } + } + /** * Adds a chapter that has just been download to this cache. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 2ad7fe4e4..e7e8d4c3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -246,6 +246,15 @@ class DownloadManager( return cache.getDownloadCount(manga) } + /** + * Returns the amount of downloaded chapters for each of the given manga, keyed by manga id. + * + * @param manga the manga to check. + */ + fun getDownloadCounts(manga: List): Map { + return cache.getDownloadCounts(manga) + } + suspend fun cancelQueuedDownloads(downloads: List) { downloader.dequeue(downloads.map { it.chapter }) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index ae354355d..999c1c2a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download import android.app.PendingIntent import android.content.Context import android.graphics.BitmapFactory +import android.os.SystemClock import androidx.core.app.NotificationCompat import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.R @@ -62,14 +63,23 @@ class DownloadNotifier( context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED) } + private var lastProgressNotificationTime = 0L + /** * Called when download progress changes. * + * Called for every downloaded page from parallel downloads, so it shares the builder under a lock and skips + * updates that come faster than the system would show them anyway. + * * @param download download object containing download information. */ - // Pages report their progress from several threads, and they share the builder @Synchronized fun onProgressChange(download: Download) { + val now = SystemClock.elapsedRealtime() + val isComplete = download.downloadedImages == download.pages?.size + if (!isComplete && now - lastProgressNotificationTime < PROGRESS_NOTIFICATION_INTERVAL_MS) return + lastProgressNotificationTime = now + with(progressNotificationBuilder) { if (!isDownloading) { context.cancelNotification(Notifications.ID_DOWNLOAD_CHAPTER_PAUSED) @@ -156,6 +166,7 @@ class DownloadNotifier( /** * Resets the state once downloads are completed. */ + @Synchronized fun onComplete() { dismissProgress() @@ -229,3 +240,5 @@ class DownloadNotifier( isDownloading = false } } + +private const val PROGRESS_NOTIFICATION_INTERVAL_MS = 500L diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index 8af9527c0..30201c1bf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt @@ -19,11 +19,14 @@ data class Download( val chapter: Chapter, ) { @Transient - private val pagesFlow = MutableStateFlow?>(null) + private val _pagesFlow = MutableStateFlow?>(null) + + @Transient + val pagesFlow = _pagesFlow.asStateFlow() var pages: List? - get() = pagesFlow.value + get() = _pagesFlow.value set(pages) { - pagesFlow.value = pages + _pagesFlow.value = pages } val totalProgress: Int @@ -44,7 +47,7 @@ data class Download( } @Transient - val progressFlow = pagesFlow + val progressFlow = _pagesFlow .flatMapLatest { pages -> if (pages == null) { flowOf(0) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index 0ce591e51..3cd677c6d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import coil3.asDrawable @@ -85,14 +86,24 @@ class LibraryUpdateNotifier( } } + private var lastProgressNotificationTime = 0L + /** * Shows the notification containing the currently updating manga and the progress. * + * Called from several update coroutines at once, so it shares the builder under a lock and skips updates that + * come faster than the system would show them anyway. + * * @param manga the manga that are being updated. * @param current the current progress. * @param total the total progress. */ + @Synchronized fun showProgressNotification(manga: List, current: Int, total: Int) { + val now = SystemClock.elapsedRealtime() + if (current < total && now - lastProgressNotificationTime < NOTIF_PROGRESS_INTERVAL_MS) return + lastProgressNotificationTime = now + progressNotificationBuilder .setContentTitle( context.stringResource( @@ -388,3 +399,4 @@ private const val NOTIF_MAX_CHAPTERS = 5 private const val NOTIF_TITLE_MAX_LEN = 45 private const val NOTIF_ICON_SIZE = 192 private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 +private const val NOTIF_PROGRESS_INTERVAL_MS = 500L diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index c1ad42bff..543fa2bfe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -367,7 +367,7 @@ internal object ExtensionLoader { if (signatures.isEmpty()) { logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion) - } else if (!trustExtension.isTrusted(pkgInfo, signatures)) { + } else if (!trustExtension.isTrusted(pkgInfo, signatures, stores)) { logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt index e63f6b353..887d86957 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import tachiyomi.i18n.MR +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Inject @@ -69,7 +70,8 @@ class ExtensionsViewModel( @Suppress("LocalVariableName") private val items = combine( searchQuery - .debounce(0.25.seconds) + // No query is the common case on every open, so don't delay it + .debounce { if (it.isNullOrEmpty()) Duration.ZERO else 0.25.seconds } .map { searchQueryPredicate(it ?: "") }, currentDownloads, getExtensions.subscribe(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt index cdaf7b2f3..4dd939406 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadQueueViewModel.kt @@ -14,7 +14,6 @@ import eu.kanade.tachiyomi.databinding.DownloadListBinding import eu.kanade.tachiyomi.source.model.Page import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed @@ -22,6 +21,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -213,11 +214,8 @@ class DownloadQueueViewModel( */ private fun launchProgressJob(download: Download) { val job = viewModelScope.launch { - while (download.pages == null) { - delay(50.milliseconds) - } - - val progressFlows = download.pages!!.map(Page::progressFlow) + val pages = download.pagesFlow.filterNotNull().first() + val progressFlows = pages.map(Page::progressFlow) combine(progressFlows, Array::sum) .distinctUntilChanged() .debounce(50.milliseconds) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt index b1dd5d7d1..5c0e0c930 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryViewModel.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -32,10 +31,12 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import logcat.LogPriority import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState +import tachiyomi.core.common.util.lang.SubscriberGate import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories @@ -71,6 +72,8 @@ class HistoryViewModel( private val sourceManager: SourceManager, ) : ViewModel() { + private val subscriberGate = SubscriberGate() + val snackbarHostState: SnackbarHostState = SnackbarHostState() private val _events: Channel = Channel(Channel.UNLIMITED) @@ -91,7 +94,7 @@ class HistoryViewModel( .map { it.toHistoryUiModels() } .flowOn(Dispatchers.Default) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) + .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList()) val state: StateFlow = combine( searchQuery, @@ -100,7 +103,7 @@ class HistoryViewModel( ) { searchQuery, history, dialog -> State(searchQuery = searchQuery, list = history, dialog = dialog) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) + .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State()) private fun List.toHistoryUiModels(): List { return map { HistoryUiModel.Item(it) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index be6af8d27..becfc91e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.library import androidx.activity.compose.BackHandler +import androidx.activity.compose.ReportDrawnWhen import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector @@ -270,6 +271,8 @@ data object LibraryTab : Tab { (context as? MainActivity)?.ready = true } } + // Lets startup be measured up to the library, not just its loading screen + ReportDrawnWhen { !state.isLoading } LaunchedEffect(Unit) { launch { queryEvent.receiveAsFlow().collect(viewModel::search) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt index 619b49e68..ec0fe47c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -44,6 +43,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import mihon.core.common.utils.mutate import mihon.core.metro.AppCoroutineScope @@ -51,7 +51,9 @@ import mihon.domain.library.model.search.QueryNode import mihon.feature.library.matches import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.TriState +import tachiyomi.core.common.util.lang.SubscriberGate import tachiyomi.core.common.util.lang.compareToWithCollator +import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.model.Category @@ -98,6 +100,8 @@ class LibraryViewModel( private val trackerManager: TrackerManager, ) : ViewModel() { + private val subscriberGate = SubscriberGate() + private val searchQuery = MutableStateFlow(null) private val selection = MutableStateFlow(emptySet()) @@ -140,7 +144,7 @@ class LibraryViewModel( .flatMapLatest { needsTracks -> if (needsTracks) getTracksPerManga.subscribe() else flowOf(emptyMap()) } // Shared separately so search, selection and dialog changes still reach [state] before the - // first query result, and so returning to the tab doesn't flash empty while it restarts. + // first query result private val library = combine( searchQuery.debounce(0.25.seconds), getCategories.subscribe(), @@ -179,7 +183,7 @@ class LibraryViewModel( ) } .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) + .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null) val state: StateFlow = combine( library, @@ -200,7 +204,7 @@ class LibraryViewModel( groupedFavorites = library?.groupedFavorites.orEmpty(), ) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) + .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State()) private data class DisplayPreferences( val showCategoryTabs: Boolean, @@ -302,10 +306,9 @@ class LibraryViewModel( trackMap: Map>, loggedInTrackerIds: Set, ): Map> { + // The collator already ignores case, and lowercasing on every comparison is slow on large libraries val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 -> - val title1 = manga1.libraryManga.manga.title.lowercase() - val title2 = manga2.libraryManga.manga.title.lowercase() - title1.compareToWithCollator(title2) + manga1.libraryManga.manga.title.compareToWithCollator(manga2.libraryManga.manga.title) } val defaultTrackerScoreSortValue = -1.0 @@ -415,14 +418,15 @@ class LibraryViewModel( return combine( getLibraryManga.subscribe(), getLibraryItemPreferencesFlow(), - downloadCache.changes, + downloadCache.changes.pausedWhileUnsubscribed(), ) { libraryManga, preferences, _ -> val sources = libraryManga .mapTo(mutableSetOf()) { it.manga.source } .associateWith { sourceManager.getOrStub(it) } + val downloadCounts = downloadManager.getDownloadCounts(libraryManga.map { it.manga }) libraryManga.map { manga -> val source = sources.getValue(manga.manga.source) - val downloadCount = downloadManager.getDownloadCount(manga.manga) + val downloadCount = downloadCounts.getValue(manga.id) LibraryItem( libraryManga = manga, downloadCount = downloadCount, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt index 459ff7ad8..f54cecb5c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt @@ -51,7 +51,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -65,9 +64,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.core.metro.AppCoroutineScope @@ -77,6 +78,8 @@ import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.mapAsCheckboxState +import tachiyomi.core.common.util.lang.SubscriberGate +import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories @@ -100,6 +103,7 @@ import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.i18n.MR import tachiyomi.source.local.isLocal import kotlin.math.floor +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @AssistedInject @@ -136,6 +140,8 @@ class MangaViewModel( private val coverCache: CoverCache, ) : ViewModel() { + private val subscriberGate = SubscriberGate() + @AssistedFactory @ManualViewModelAssistedFactoryKey @ContributesIntoMap(AppScope::class) @@ -206,8 +212,8 @@ class MangaViewModel( emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true)) } .distinctUntilChanged(), - downloadCache.changes, - downloadManager.queueState, + downloadCache.changes.pausedWhileUnsubscribed(), + downloadManager.queueState.pausedWhileUnsubscribed(), ) { (manga, chapters), _, _ -> MangaAndChapters( manga = manga, @@ -242,7 +248,7 @@ class MangaViewModel( mangaAndChapters, scanlators, trackers, - combine(selectedChapterIds, queuedDownloadStates, ::Pair), + combine(selectedChapterIds, queuedDownloadStates.pausedWhileUnsubscribed(), ::Pair), combine(dialog, isRefreshingData, ::Pair), ) { mangaAndChapters, @@ -281,12 +287,16 @@ class MangaViewModel( it.isAnySelected } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) + .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State.Loading) init { // Progress ticks often, and each one copies the map of download states viewModelScope.launch(Dispatchers.Default) { - merge(downloadManager.statusFlow(), downloadManager.progressFlow()) + merge( + downloadManager.statusFlow(), + // Each tick rebuilds the shown chapter items, so cap how often that happens + downloadManager.progressFlow().sample(200.milliseconds), + ) .filter { it.manga.id == mangaId } .catch { logcat(LogPriority.ERROR, it) } .collect(::updateDownloadState) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt index c201f79ff..73f527714 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -59,8 +59,10 @@ import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -777,6 +779,8 @@ data class TrackerSearchScreen( private val tracker = trackerManager.get(trackerId)!! + private var searchJob: Job? = null + val supportsPrivateTracking = tracker.supportsPrivateTracking init { @@ -787,7 +791,8 @@ data class TrackerSearchScreen( } fun trackingSearch(query: String) { - viewModelScope.launch { + searchJob?.cancel() + searchJob = viewModelScope.launch { // To show loading state state.update { it.copy(queryResult = null, selected = null) } @@ -796,6 +801,7 @@ data class TrackerSearchScreen( val results = tracker.search(query) Result.success(results) } catch (e: Throwable) { + if (e is CancellationException) throw e Result.failure(e) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 697357ca5..c89692619 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -95,6 +96,7 @@ import tachiyomi.domain.source.service.SourceManager import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.isLocal import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** @@ -197,6 +199,11 @@ class ReaderViewModel( // Serializes the progress writes, which outlive the reader, so they are applied in order private val progressWrites = Mutex() + // Set on Main, flushed from the chapter load too + @Volatile + private var pendingProgressChapter: ReaderChapter? = null + private var pendingProgressSave: Job? = null + private var chapterToDownload: Download? = null private var unfilteredChapterList: List? = null @@ -540,10 +547,8 @@ class ReaderViewModel( val selectedChapter = page.chapter val pages = selectedChapter.pages ?: return - // Save last page read and mark as read if needed. The database calls are main-safe. - appScope.launch(Dispatchers.Main.immediate) { - updateChapterProgress(selectedChapter, page) - } + // Save last page read and mark as read if needed + updateChapterProgress(selectedChapter, page) if (selectedChapter != getCurrentChapter()) { logcat { "Setting ${selectedChapter.chapter.url} as active" } @@ -639,8 +644,11 @@ class ReaderViewModel( /** * Saves the chapter progress (last read page and whether it's read) * if incognito mode isn't on. + * + * Completing a chapter is saved right away. Other page changes are saved once the reader settles on a page, or + * when [saveChapterProgress] is called, so flipping through pages doesn't write to the database on every page. */ - private suspend fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) { + private fun updateChapterProgress(readerChapter: ReaderChapter, page: Page) { val pageIndex = page.index mutableState.update { @@ -649,22 +657,49 @@ class ReaderViewModel( readerChapter.requestedPage = pageIndex chapterPageIndex = pageIndex - if (!incognitoMode && page.status !is Page.State.Error) { - val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex - readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) } - val isLastPage = readerChapter.pages?.lastIndex == pageIndex + if (incognitoMode || page.status is Page.State.Error) return - // 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 + val read = readerChapter.chapter.read || readerChapter.pages?.lastIndex == pageIndex + // A page change in another chapter would replace that chapter's pending save + if (pendingProgressChapter != readerChapter) saveChapterProgress() + readerChapter.update { it.copy(lastPageRead = pageIndex, read = read) } + pendingProgressSave?.cancel() + + if (readerChapter.pages?.lastIndex == pageIndex) { + pendingProgressChapter = null + writeChapterProgress(readerChapter, onComplete = true) + } else { + pendingProgressChapter = readerChapter + pendingProgressSave = viewModelScope.launch { + delay(PROGRESS_SAVE_DELAY) + saveChapterProgress() + } + } + } + + /** + * Writes the progress of the chapter whose page change hasn't been saved yet. + */ + private fun saveChapterProgress() { + val readerChapter = pendingProgressChapter ?: return + pendingProgressChapter = null + writeChapterProgress(readerChapter, onComplete = false) + } + + /** + * Writes the progress of [readerChapter] on the app scope, so it's saved even when the reader is closing. + */ + private fun writeChapterProgress(readerChapter: ReaderChapter, onComplete: Boolean) { + // Immediate and through the mutex, which is fair, so the writes land in the order they were made + appScope.launch(Dispatchers.Main.immediate) { progressWrites.withLock { - if (isLastPage) { - updateChapterProgressOnComplete(readerChapter) - } + if (onComplete) updateChapterProgressOnComplete(readerChapter) + val chapter = readerChapter.chapter updateChapter.await( - ChapterUpdate(readerChapter.chapter.id) { - this.read = read - lastPageRead = pageIndex + ChapterUpdate(chapter.id) { + read = chapter.read + lastPageRead = chapter.lastPageRead }, ) } @@ -710,6 +745,7 @@ class ReaderViewModel( * Saves the chapter last read history if incognito mode isn't on. */ private suspend fun updateHistory() { + saveChapterProgress() getCurrentChapter()?.let { readerChapter -> if (incognitoMode) return@let @@ -1109,3 +1145,5 @@ class ReaderViewModel( data class CopyImage(val uri: Uri) : Event } } + +private val PROGRESS_SAVE_DELAY = 2.seconds diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt index 40f6bebe5..6b3a76485 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -40,15 +39,19 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import logcat.LogPriority import mihon.core.metro.AppCoroutineScope import tachiyomi.core.common.preference.TriState +import tachiyomi.core.common.util.lang.SubscriberGate +import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.UpdateChapter @@ -61,6 +64,7 @@ import tachiyomi.domain.updates.interactor.GetUpdates import tachiyomi.domain.updates.model.UpdatesWithRelations import tachiyomi.domain.updates.service.UpdatesPreferences import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Inject @@ -81,6 +85,8 @@ class UpdatesViewModel( private val updatesPreferences: UpdatesPreferences, ) : ViewModel() { + private val subscriberGate = SubscriberGate() + val snackbarHostState: SnackbarHostState = SnackbarHostState() private val _events: Channel = Channel(Int.MAX_VALUE) @@ -105,7 +111,8 @@ class UpdatesViewModel( init { viewModelScope.launch { - merge(downloadManager.statusFlow(), downloadManager.progressFlow()) + // Each progress tick rebuilds every row of the list, so cap how often that happens + merge(downloadManager.statusFlow(), downloadManager.progressFlow().sample(200.milliseconds)) .catch { logcat(LogPriority.ERROR, it) } .collect(this@UpdatesViewModel::updateDownloadState) } @@ -157,8 +164,8 @@ class UpdatesViewModel( excludedCategories = it.filterExcludedCategories, ).distinctUntilChanged() }, - downloadCache.changes, - downloadManager.queueState, + downloadCache.changes.pausedWhileUnsubscribed(), + downloadManager.queueState.pausedWhileUnsubscribed(), // needed for Kotlin filters (downloaded) getUpdatesItemPreferenceFlow().distinctUntilChanged { old, new -> old.filterDownloaded == new.filterDownloaded @@ -169,12 +176,12 @@ class UpdatesViewModel( .applyFilters(itemPreferences) } .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) + .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, null) val state: StateFlow = combine( updateItems, selectedChapterIds, - queuedDownloadStates, + queuedDownloadStates.pausedWhileUnsubscribed(), dialog, hasActiveFilters, ) { items, selectedIds, downloads, dialog, hasActiveFilters -> @@ -201,7 +208,7 @@ class UpdatesViewModel( ) } .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) + .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State()) private fun List.applyFilters( preferences: ItemPreferences, diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt index 396922fec..98f33588b 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListViewModel.kt @@ -38,7 +38,7 @@ import mihon.feature.migration.list.models.MigratingManga import mihon.feature.migration.list.models.MigratingManga.SearchResult import mihon.feature.migration.list.search.SmartSourceSearchEngine import tachiyomi.core.common.util.system.logcat -import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId +import tachiyomi.domain.chapter.interactor.GetChapterSummaryByMangaId import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.model.Manga @@ -52,7 +52,7 @@ class MigrationListViewModel( private val sourceManager: SourceManager, private val getManga: GetManga, private val networkToLocalManga: NetworkToLocalManga, - private val getChaptersByMangaId: GetChaptersByMangaId, + private val getChapterSummaryByMangaId: GetChapterSummaryByMangaId, private val migrateManga: MigrateMangaUseCase, private val updateMangaFromRemote: UpdateMangaFromRemote, ) : ViewModel() { @@ -103,10 +103,10 @@ class MigrationListViewModel( } } - private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters -> + private suspend fun getChapterInfo(id: Long) = getChapterSummaryByMangaId.await(id).let { summary -> ChapterInfo( - latestChapter = chapters.maxOfOrNull { it.chapterNumber }, - chapterCount = chapters.size, + latestChapter = summary.latestChapterNumber, + chapterCount = summary.chapterCount.toInt(), ) } diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt index 04562362b..9246447b4 100644 --- a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth @@ -31,6 +31,7 @@ import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.yearMonth import mihon.domain.upcoming.interactor.GetUpcomingManga import tachiyomi.core.common.preference.getAndSet +import tachiyomi.core.common.util.lang.SubscriberGate import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.model.Category import tachiyomi.domain.manga.model.Manga @@ -47,6 +48,8 @@ class UpcomingViewModel( val upcomingPreferences: UpcomingPreferences, ) : ViewModel() { + private val subscriberGate = SubscriberGate() + val excludedCategories = upcomingPreferences.filterExcludedCategories val includedCategories = upcomingPreferences.filterIncludedCategories @@ -80,7 +83,7 @@ class UpcomingViewModel( .distinctUntilChanged() .map { it.toUpcomingUIModels() } .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), emptyList()) + .stateIn(viewModelScope + subscriberGate, SharingStarted.Eagerly, emptyList()) val state: StateFlow = combine( upcoming, @@ -98,8 +101,8 @@ class UpcomingViewModel( ) } .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds), + viewModelScope + subscriberGate, + subscriberGate.whileSubscribed(5.seconds), State(selectedYearMonth = selectedYearMonth.value), ) diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/SubscriberGate.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/SubscriberGate.kt new file mode 100644 index 000000000..e1a223a7d --- /dev/null +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/lang/SubscriberGate.kt @@ -0,0 +1,60 @@ +package tachiyomi.core.common.util.lang + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingCommand +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration + +/** + * Keeps a view model's flows running while its screen is hidden, but holds back database and other frequent + * updates until the screen is visible again, so returning to it shows the last state instead of re-running + * everything. Flows collected without a gate in their context are unaffected. + */ +class SubscriberGate : AbstractCoroutineContextElement(SubscriberGate) { + + companion object Key : CoroutineContext.Key + + private val open = MutableStateFlow(true) + + fun whileSubscribed(stopTimeout: Duration): SharingStarted = object : SharingStarted { + override fun command(subscriptionCount: StateFlow): Flow = flow { + emit(SharingCommand.START) + subscriptionCount + .map { it > 0 } + .distinctUntilChanged() + .collectLatest { subscribed -> + if (!subscribed) delay(stopTimeout) + open.value = subscribed + } + } + } + + internal suspend fun awaitOpen() { + open.first { it } + } +} + +fun Flow.pausedWhileUnsubscribed(): Flow = flow { + val gate = currentCoroutineContext()[SubscriberGate] + if (gate == null) { + emitAll(this@pausedWhileUnsubscribed) + } else { + this@pausedWhileUnsubscribed.conflate().collect { + gate.awaitOpen() + emit(it) + } + } +} diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt index c28695ddd..b40468474 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt @@ -318,8 +318,16 @@ object ImageUtil { null } + if (image == null) return Color.WHITE.toDrawable() + return try { + chooseBackground(context, image) + } finally { + image.recycle() + } + } + + private fun chooseBackground(context: Context, image: Bitmap): Drawable { val whiteColor = Color.WHITE - if (image == null) return whiteColor.toDrawable() if (image.width < 50 || image.height < 50) { return whiteColor.toDrawable() } diff --git a/data/src/main/java/tachiyomi/data/QueryExtension.kt b/data/src/main/java/tachiyomi/data/QueryExtension.kt index 6463c0c81..91fb26e53 100644 --- a/data/src/main/java/tachiyomi/data/QueryExtension.kt +++ b/data/src/main/java/tachiyomi/data/QueryExtension.kt @@ -6,17 +6,18 @@ import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOne import app.cash.sqldelight.coroutines.mapToOneOrNull import kotlinx.coroutines.flow.Flow +import tachiyomi.core.common.util.lang.pausedWhileUnsubscribed import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext fun Query.subscribeToList( context: CoroutineContext = EmptyCoroutineContext, -): Flow> = asFlow().mapToList(context) +): Flow> = asFlow().pausedWhileUnsubscribed().mapToList(context) fun Query.subscribeToOne( context: CoroutineContext = EmptyCoroutineContext, -): Flow = asFlow().mapToOne(context) +): Flow = asFlow().pausedWhileUnsubscribed().mapToOne(context) fun Query.subscribeToOneOrNull( context: CoroutineContext = EmptyCoroutineContext, -): Flow = asFlow().mapToOneOrNull(context) +): Flow = asFlow().pausedWhileUnsubscribed().mapToOneOrNull(context) diff --git a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt index cfd3a35bf..980a58870 100644 --- a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt @@ -14,6 +14,7 @@ import tachiyomi.data.Database import tachiyomi.data.subscribeToList import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.ChapterRemoteUpdate +import tachiyomi.domain.chapter.model.ChapterSummary import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.repository.ChapterRepository import kotlin.time.Instant @@ -112,6 +113,14 @@ class ChapterRepositoryImpl( .awaitAsList() } + override suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary { + return database.chapterQueries + .getChapterSummaryByMangaId(mangaId) { total, latestChapterNumber -> + ChapterSummary(chapterCount = total, latestChapterNumber = latestChapterNumber) + } + .awaitAsOne() + } + override suspend fun getScanlatorsByMangaId(mangaId: Long): List { return database.chapterQueries .getScanlatorsByMangaId(mangaId) { it.orEmpty() } diff --git a/data/src/main/sqldelight/tachiyomi/data/chapter.sq b/data/src/main/sqldelight/tachiyomi/data/chapter.sq index 73f5996a4..d96d63c49 100644 --- a/data/src/main/sqldelight/tachiyomi/data/chapter.sq +++ b/data/src/main/sqldelight/tachiyomi/data/chapter.sq @@ -48,6 +48,11 @@ AND ( OR ES.scanlator IS NULL ); +getChapterSummaryByMangaId: +SELECT count(*) AS total, max(remote_chapter_number) AS latestChapterNumber +FROM chapter +WHERE manga_id = :mangaId; + getScanlatorsByMangaId: SELECT DISTINCT remote_scanlator FROM chapter diff --git a/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChapterSummaryByMangaId.kt b/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChapterSummaryByMangaId.kt new file mode 100644 index 000000000..ea524dc71 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChapterSummaryByMangaId.kt @@ -0,0 +1,15 @@ +package tachiyomi.domain.chapter.interactor + +import dev.zacsweers.metro.Inject +import tachiyomi.domain.chapter.model.ChapterSummary +import tachiyomi.domain.chapter.repository.ChapterRepository + +@Inject +class GetChapterSummaryByMangaId( + private val chapterRepository: ChapterRepository, +) { + + suspend fun await(mangaId: Long): ChapterSummary { + return chapterRepository.getChapterSummaryByMangaId(mangaId) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterSummary.kt b/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterSummary.kt new file mode 100644 index 000000000..0bec289f3 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/chapter/model/ChapterSummary.kt @@ -0,0 +1,6 @@ +package tachiyomi.domain.chapter.model + +data class ChapterSummary( + val chapterCount: Long, + val latestChapterNumber: Double?, +) diff --git a/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt b/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt index c2b72d22b..e39aa1e72 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt @@ -3,6 +3,7 @@ package tachiyomi.domain.chapter.repository import kotlinx.coroutines.flow.Flow import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.ChapterRemoteUpdate +import tachiyomi.domain.chapter.model.ChapterSummary import tachiyomi.domain.chapter.model.ChapterUpdate interface ChapterRepository { @@ -19,6 +20,8 @@ interface ChapterRepository { suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List + suspend fun getChapterSummaryByMangaId(mangaId: Long): ChapterSummary + suspend fun getScanlatorsByMangaId(mangaId: Long): List fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow> diff --git a/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridCoverScreenGlanceReceiver.kt b/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridCoverScreenGlanceReceiver.kt index 721c6a3ac..12d52443b 100644 --- a/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridCoverScreenGlanceReceiver.kt +++ b/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridCoverScreenGlanceReceiver.kt @@ -1,9 +1,20 @@ package tachiyomi.presentation.widget +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver class UpdatesGridCoverScreenGlanceReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget get() = UpdatesGridCoverScreenGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetManager.onWidgetEnabled(glanceAppWidget) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetManager.onWidgetDisabled(glanceAppWidget) + } } diff --git a/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridGlanceReceiver.kt b/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridGlanceReceiver.kt index 99558d43e..08fad305e 100644 --- a/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridGlanceReceiver.kt +++ b/presentation-widget/src/main/java/tachiyomi/presentation/widget/UpdatesGridGlanceReceiver.kt @@ -1,5 +1,6 @@ package tachiyomi.presentation.widget +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver @@ -7,4 +8,14 @@ class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget get() = UpdatesGridGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetManager.onWidgetEnabled(glanceAppWidget) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetManager.onWidgetDisabled(glanceAppWidget) + } } diff --git a/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt b/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt index e9aebcb2b..8b8ccd4fe 100644 --- a/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt +++ b/presentation-widget/src/main/java/tachiyomi/presentation/widget/WidgetManager.kt @@ -1,16 +1,25 @@ package tachiyomi.presentation.widget import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import dev.zacsweers.metro.Inject import eu.kanade.tachiyomi.core.security.SecurityPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.updates.interactor.GetUpdates @@ -21,21 +30,40 @@ class WidgetManager( private val securityPreferences: SecurityPreferences, ) { + @OptIn(ExperimentalCoroutinesApi::class) context(context: Context) fun init(scope: CoroutineScope) { - combine( - getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit), - securityPreferences.useAuthenticator.changes(), - transform = { a, b -> a to b }, - ) + scope.launch(Dispatchers.Default) { + val manager = GlanceAppWidgetManager(context) + val placed = widgets.filter { manager.getGlanceIds(it.javaClass).isNotEmpty() } + placedWidgets.update { it + placed.map { widget -> widget.javaClass } } + } + + // Only watch updates while a widget is on the home screen + placedWidgets + .map { it.isNotEmpty() } + .distinctUntilChanged() + .flatMapLatest { hasWidgets -> + if (hasWidgets) { + combine( + getUpdates.subscribe( + read = false, + after = BaseUpdatesGridGlanceWidget.DateLimit, + ), + securityPreferences.useAuthenticator.changes(), + transform = { a, b -> a to b }, + ) + } else { + emptyFlow() + } + } .distinctUntilChanged { old, new -> old.second == new.second && old.first.map { it.chapterId }.toSet() == new.first.map { it.chapterId }.toSet() } .onEach { try { - UpdatesGridGlanceWidget().updateAll(context) - UpdatesGridCoverScreenGlanceWidget().updateAll(context) + widgets.forEach { it.updateAll(context) } } catch (e: Exception) { this.logcat(LogPriority.ERROR, e) { "Failed to update widget" } } @@ -43,4 +71,19 @@ class WidgetManager( .flowOn(Dispatchers.Default) .launchIn(scope) } + + companion object { + private val widgets: List + get() = listOf(UpdatesGridGlanceWidget(), UpdatesGridCoverScreenGlanceWidget()) + + private val placedWidgets = MutableStateFlow(emptySet>()) + + internal fun onWidgetEnabled(widget: GlanceAppWidget) { + placedWidgets.update { it + widget.javaClass } + } + + internal fun onWidgetDisabled(widget: GlanceAppWidget) { + placedWidgets.update { it - widget.javaClass } + } + } }